Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
9064ee589c fix(deps): update dependency dotenv to v17 2025-10-28 17:07:57 +00:00
52 changed files with 1712 additions and 4407 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon-backend",
"version": "1.3.2",
"version": "1.3.1",
"description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0",
"main": "src/server.js",
@@ -22,7 +22,7 @@
"bullmq": "^5.61.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"dotenv": "^17.0.0",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-validator": "^7.2.0",

View File

@@ -1,74 +0,0 @@
-- CreateTable
CREATE TABLE "docker_volumes" (
"id" TEXT NOT NULL,
"host_id" TEXT NOT NULL,
"volume_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"driver" TEXT NOT NULL,
"mountpoint" TEXT,
"renderer" TEXT,
"scope" TEXT NOT NULL DEFAULT 'local',
"labels" JSONB,
"options" JSONB,
"size_bytes" BIGINT,
"ref_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "docker_volumes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "docker_networks" (
"id" TEXT NOT NULL,
"host_id" TEXT NOT NULL,
"network_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"driver" TEXT NOT NULL,
"scope" TEXT NOT NULL DEFAULT 'local',
"ipv6_enabled" BOOLEAN NOT NULL DEFAULT false,
"internal" BOOLEAN NOT NULL DEFAULT false,
"attachable" BOOLEAN NOT NULL DEFAULT true,
"ingress" BOOLEAN NOT NULL DEFAULT false,
"config_only" BOOLEAN NOT NULL DEFAULT false,
"labels" JSONB,
"ipam" JSONB,
"container_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL,
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "docker_networks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "docker_volumes_host_id_idx" ON "docker_volumes"("host_id");
-- CreateIndex
CREATE INDEX "docker_volumes_name_idx" ON "docker_volumes"("name");
-- CreateIndex
CREATE INDEX "docker_volumes_driver_idx" ON "docker_volumes"("driver");
-- CreateIndex
CREATE UNIQUE INDEX "docker_volumes_host_id_volume_id_key" ON "docker_volumes"("host_id", "volume_id");
-- CreateIndex
CREATE INDEX "docker_networks_host_id_idx" ON "docker_networks"("host_id");
-- CreateIndex
CREATE INDEX "docker_networks_name_idx" ON "docker_networks"("name");
-- CreateIndex
CREATE INDEX "docker_networks_driver_idx" ON "docker_networks"("driver");
-- CreateIndex
CREATE UNIQUE INDEX "docker_networks_host_id_network_id_key" ON "docker_networks"("host_id", "network_id");
-- AddForeignKey
ALTER TABLE "docker_volumes" ADD CONSTRAINT "docker_volumes_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "docker_networks" ADD CONSTRAINT "docker_networks_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,6 +0,0 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "theme_preference" VARCHAR(10) DEFAULT 'dark';
-- AlterTable
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "color_theme" VARCHAR(50) DEFAULT 'cyber_blue';

View File

@@ -114,8 +114,6 @@ model hosts {
host_group_memberships host_group_memberships[]
update_history update_history[]
job_history job_history[]
docker_volumes docker_volumes[]
docker_networks docker_networks[]
@@index([machine_id])
@@index([friendly_name])
@@ -196,6 +194,7 @@ model settings {
metrics_enabled Boolean @default(true)
metrics_anonymous_id String?
metrics_last_sent DateTime?
color_theme String @default("default")
}
model update_history {
@@ -227,8 +226,6 @@ model users {
tfa_secret String?
first_name String?
last_name String?
theme_preference String? @default("dark")
color_theme String? @default("cyber_blue")
dashboard_preferences dashboard_preferences[]
user_sessions user_sessions[]
auto_enrollment_tokens auto_enrollment_tokens[]
@@ -345,56 +342,6 @@ model docker_image_updates {
@@index([is_security_update])
}
model docker_volumes {
id String @id
host_id String
volume_id String
name String
driver String
mountpoint String?
renderer String?
scope String @default("local")
labels Json?
options Json?
size_bytes BigInt?
ref_count Int @default(0)
created_at DateTime
updated_at DateTime
last_checked DateTime @default(now())
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
@@unique([host_id, volume_id])
@@index([host_id])
@@index([name])
@@index([driver])
}
model docker_networks {
id String @id
host_id String
network_id String
name String
driver String
scope String @default("local")
ipv6_enabled Boolean @default(false)
internal Boolean @default(false)
attachable Boolean @default(true)
ingress Boolean @default(false)
config_only Boolean @default(false)
labels Json?
ipam Json? // IPAM configuration (driver, config, options)
container_count Int @default(0)
created_at DateTime?
updated_at DateTime
last_checked DateTime @default(now())
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
@@unique([host_id, network_id])
@@index([host_id])
@@index([name])
@@index([driver])
}
model job_history {
id String @id
job_id String

View File

@@ -17,7 +17,6 @@ const {
refresh_access_token,
revoke_session,
revoke_all_user_sessions,
generate_device_fingerprint,
} = require("../utils/session_manager");
const router = express.Router();
@@ -789,39 +788,11 @@ router.post(
// Check if TFA is enabled
if (user.tfa_enabled) {
// Get device fingerprint from X-Device-ID header
const device_fingerprint = generate_device_fingerprint(req);
// Check if this device has a valid TFA bypass
if (device_fingerprint) {
const remembered_session = await prisma.user_sessions.findFirst({
where: {
user_id: user.id,
device_fingerprint: device_fingerprint,
tfa_remember_me: true,
tfa_bypass_until: { gt: new Date() }, // Bypass still valid
},
});
if (remembered_session) {
// Device is remembered and bypass is still valid - skip TFA
// Continue with login below
} else {
// No valid bypass for this device - require TFA
return res.status(200).json({
message: "TFA verification required",
requiresTfa: true,
username: user.username,
});
}
} else {
// No device ID provided - require TFA
return res.status(200).json({
message: "TFA verification required",
requiresTfa: true,
username: user.username,
});
}
return res.status(200).json({
message: "TFA verification required",
requiresTfa: true,
username: user.username,
});
}
// Update last login
@@ -836,13 +807,7 @@ router.post(
// Create session with access and refresh tokens
const ip_address = req.ip || req.connection.remoteAddress;
const user_agent = req.get("user-agent");
const session = await create_session(
user.id,
ip_address,
user_agent,
false,
req,
);
const session = await create_session(user.id, ip_address, user_agent);
res.json({
message: "Login successful",
@@ -860,9 +825,6 @@ router.post(
last_login: user.last_login,
created_at: user.created_at,
updated_at: user.updated_at,
// Include user preferences so they're available immediately after login
theme_preference: user.theme_preference,
color_theme: user.color_theme,
},
});
} catch (error) {
@@ -879,10 +841,8 @@ router.post(
body("username").notEmpty().withMessage("Username is required"),
body("token")
.isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 characters"),
body("token")
.matches(/^[A-Z0-9]{6}$/)
.withMessage("Token must be 6 alphanumeric characters"),
.withMessage("Token must be 6 digits"),
body("token").isNumeric().withMessage("Token must contain only numbers"),
body("remember_me")
.optional()
.isBoolean()
@@ -955,24 +915,10 @@ router.post(
return res.status(401).json({ error: "Invalid verification code" });
}
// Update last login and fetch complete user data
const updatedUser = await prisma.users.update({
// Update last login
await prisma.users.update({
where: { id: user.id },
data: { last_login: new Date() },
select: {
id: true,
username: true,
email: true,
first_name: true,
last_name: true,
role: true,
is_active: true,
last_login: true,
created_at: true,
updated_at: true,
theme_preference: true,
color_theme: true,
},
});
// Create session with access and refresh tokens
@@ -992,7 +938,14 @@ router.post(
refresh_token: session.refresh_token,
expires_at: session.expires_at,
tfa_bypass_until: session.tfa_bypass_until,
user: updatedUser,
user: {
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
},
});
} catch (error) {
console.error("TFA verification error:", error);
@@ -1024,27 +977,13 @@ router.put(
.withMessage("Username must be at least 3 characters"),
body("email").optional().isEmail().withMessage("Valid email is required"),
body("first_name")
.optional({ nullable: true, checkFalsy: true })
.custom((value) => {
// Allow null, undefined, or empty string to clear the field
if (value === null || value === undefined || value === "") {
return true;
}
// If provided, must be at least 1 character after trimming
return typeof value === "string" && value.trim().length >= 1;
})
.withMessage("First name must be at least 1 character if provided"),
.optional()
.isLength({ min: 1 })
.withMessage("First name must be at least 1 character"),
body("last_name")
.optional({ nullable: true, checkFalsy: true })
.custom((value) => {
// Allow null, undefined, or empty string to clear the field
if (value === null || value === undefined || value === "") {
return true;
}
// If provided, must be at least 1 character after trimming
return typeof value === "string" && value.trim().length >= 1;
})
.withMessage("Last name must be at least 1 character if provided"),
.optional()
.isLength({ min: 1 })
.withMessage("Last name must be at least 1 character"),
],
async (req, res) => {
try {
@@ -1054,27 +993,12 @@ router.put(
}
const { username, email, first_name, last_name } = req.body;
const updateData = {
updated_at: new Date(),
};
const updateData = {};
// Handle all fields consistently - trim and update if provided
if (username) updateData.username = username.trim();
if (email) updateData.email = email.trim();
if (first_name !== undefined) {
// Allow null or empty string to clear the field, otherwise trim
updateData.first_name =
first_name === "" || first_name === null
? null
: first_name.trim() || null;
}
if (last_name !== undefined) {
// Allow null or empty string to clear the field, otherwise trim
updateData.last_name =
last_name === "" || last_name === null
? null
: last_name.trim() || null;
}
if (username) updateData.username = username;
if (email) updateData.email = email;
if (first_name !== undefined) updateData.first_name = first_name || null;
if (last_name !== undefined) updateData.last_name = last_name || null;
// Check if username/email already exists (excluding current user)
if (username || email) {
@@ -1099,7 +1023,6 @@ router.put(
}
}
// Update user with explicit commit
const updatedUser = await prisma.users.update({
where: { id: req.user.id },
data: updateData,
@@ -1116,29 +1039,9 @@ router.put(
},
});
// Explicitly refresh user data from database to ensure we return latest data
// This ensures consistency especially in high-concurrency scenarios
const freshUser = await prisma.users.findUnique({
where: { id: req.user.id },
select: {
id: true,
username: true,
email: true,
first_name: true,
last_name: true,
role: true,
is_active: true,
last_login: true,
updated_at: true,
},
});
// Use fresh data if available, otherwise fallback to updatedUser
const responseUser = freshUser || updatedUser;
res.json({
message: "Profile updated successfully",
user: responseUser,
user: updatedUser,
});
} catch (error) {
console.error("Update profile error:", error);

View File

@@ -573,7 +573,6 @@ router.post("/collect", async (req, res) => {
image_id: containerData.image_id || "unknown",
source: containerData.image_source || "docker-hub",
created_at: parseDate(containerData.created_at),
last_checked: now,
updated_at: now,
},
});
@@ -823,7 +822,6 @@ router.post("/../integrations/docker", async (req, res) => {
image_id: containerData.image_id || "unknown",
source: containerData.image_source || "docker-hub",
created_at: parseDate(containerData.created_at),
last_checked: now,
updated_at: now,
},
});
@@ -878,12 +876,6 @@ router.post("/../integrations/docker", async (req, res) => {
if (images && Array.isArray(images)) {
console.log(`[Docker Integration] Processing ${images.length} images`);
for (const imageData of images) {
// If image has no digest, it's likely locally built - override source to "local"
const imageSource =
!imageData.digest || imageData.digest.trim() === ""
? "local"
: imageData.source || "docker-hub";
await prisma.docker_images.upsert({
where: {
repository_tag_image_id: {
@@ -897,7 +889,6 @@ router.post("/../integrations/docker", async (req, res) => {
? BigInt(imageData.size_bytes)
: null,
digest: imageData.digest || null,
source: imageSource, // Update source in case it changed
last_checked: now,
updated_at: now,
},
@@ -910,9 +901,8 @@ router.post("/../integrations/docker", async (req, res) => {
size_bytes: imageData.size_bytes
? BigInt(imageData.size_bytes)
: null,
source: imageSource,
source: imageData.source || "docker-hub",
created_at: parseDate(imageData.created_at),
last_checked: now,
updated_at: now,
},
});
@@ -1072,172 +1062,6 @@ router.delete("/images/:id", authenticateToken, async (req, res) => {
}
});
// GET /api/v1/docker/volumes - Get all volumes with filters
router.get("/volumes", authenticateToken, async (req, res) => {
try {
const { driver, search, page = 1, limit = 50 } = req.query;
const where = {};
if (driver) where.driver = driver;
if (search) {
where.OR = [{ name: { contains: search, mode: "insensitive" } }];
}
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const take = parseInt(limit, 10);
const [volumes, total] = await Promise.all([
prisma.docker_volumes.findMany({
where,
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
},
},
},
orderBy: { updated_at: "desc" },
skip,
take,
}),
prisma.docker_volumes.count({ where }),
]);
res.json(
convertBigIntToString({
volumes,
pagination: {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
total,
totalPages: Math.ceil(total / parseInt(limit, 10)),
},
}),
);
} catch (error) {
console.error("Error fetching volumes:", error);
res.status(500).json({ error: "Failed to fetch volumes" });
}
});
// GET /api/v1/docker/volumes/:id - Get volume detail
router.get("/volumes/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const volume = await prisma.docker_volumes.findUnique({
where: { id },
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
os_type: true,
os_version: true,
},
},
},
});
if (!volume) {
return res.status(404).json({ error: "Volume not found" });
}
res.json(convertBigIntToString({ volume }));
} catch (error) {
console.error("Error fetching volume detail:", error);
res.status(500).json({ error: "Failed to fetch volume detail" });
}
});
// GET /api/v1/docker/networks - Get all networks with filters
router.get("/networks", authenticateToken, async (req, res) => {
try {
const { driver, search, page = 1, limit = 50 } = req.query;
const where = {};
if (driver) where.driver = driver;
if (search) {
where.OR = [{ name: { contains: search, mode: "insensitive" } }];
}
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const take = parseInt(limit, 10);
const [networks, total] = await Promise.all([
prisma.docker_networks.findMany({
where,
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
},
},
},
orderBy: { updated_at: "desc" },
skip,
take,
}),
prisma.docker_networks.count({ where }),
]);
res.json(
convertBigIntToString({
networks,
pagination: {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
total,
totalPages: Math.ceil(total / parseInt(limit, 10)),
},
}),
);
} catch (error) {
console.error("Error fetching networks:", error);
res.status(500).json({ error: "Failed to fetch networks" });
}
});
// GET /api/v1/docker/networks/:id - Get network detail
router.get("/networks/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const network = await prisma.docker_networks.findUnique({
where: { id },
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
os_type: true,
os_version: true,
},
},
},
});
if (!network) {
return res.status(404).json({ error: "Network not found" });
}
res.json(convertBigIntToString({ network }));
} catch (error) {
console.error("Error fetching network detail:", error);
res.status(500).json({ error: "Failed to fetch network detail" });
}
});
// GET /api/v1/docker/agent - Serve the Docker agent installation script
router.get("/agent", async (_req, res) => {
try {
@@ -1269,66 +1093,4 @@ router.get("/agent", async (_req, res) => {
}
});
// DELETE /api/v1/docker/volumes/:id - Delete a volume
router.delete("/volumes/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// Check if volume exists
const volume = await prisma.docker_volumes.findUnique({
where: { id },
});
if (!volume) {
return res.status(404).json({ error: "Volume not found" });
}
// Delete the volume
await prisma.docker_volumes.delete({
where: { id },
});
console.log(`🗑️ Deleted volume: ${volume.name} (${id})`);
res.json({
success: true,
message: `Volume ${volume.name} deleted successfully`,
});
} catch (error) {
console.error("Error deleting volume:", error);
res.status(500).json({ error: "Failed to delete volume" });
}
});
// DELETE /api/v1/docker/networks/:id - Delete a network
router.delete("/networks/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// Check if network exists
const network = await prisma.docker_networks.findUnique({
where: { id },
});
if (!network) {
return res.status(404).json({ error: "Network not found" });
}
// Delete the network
await prisma.docker_networks.delete({
where: { id },
});
console.log(`🗑️ Deleted network: ${network.name} (${id})`);
res.json({
success: true,
message: `Network ${network.name} deleted successfully`,
});
} catch (error) {
console.error("Error deleting network:", error);
res.status(500).json({ error: "Failed to delete network" });
}
});
module.exports = router;

View File

@@ -24,15 +24,7 @@ router.get("/", authenticateToken, async (_req, res) => {
},
});
// Transform the count field to match frontend expectations
const transformedGroups = hostGroups.map((group) => ({
...group,
_count: {
hosts: group._count.host_group_memberships,
},
}));
res.json(transformedGroups);
res.json(hostGroups);
} catch (error) {
console.error("Error fetching host groups:", error);
res.status(500).json({ error: "Failed to fetch host groups" });

View File

@@ -10,7 +10,6 @@ const {
requireManageHosts,
requireManageSettings,
} = require("../middleware/permissions");
const { queueManager, QUEUE_NAMES } = require("../services/automation");
const router = express.Router();
const prisma = getPrismaClient();
@@ -1388,66 +1387,6 @@ router.delete(
},
);
// Force immediate report from agent
router.post(
"/:hostId/fetch-report",
authenticateToken,
requireManageHosts,
async (req, res) => {
try {
const { hostId } = req.params;
// Get host to verify it exists
const host = await prisma.hosts.findUnique({
where: { id: hostId },
});
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
// Get the agent-commands queue
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
if (!queue) {
return res.status(500).json({
error: "Queue not available",
});
}
// Add job to queue
const job = await queue.add(
"report_now",
{
api_id: host.api_id,
type: "report_now",
},
{
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
},
);
res.json({
success: true,
message: "Report fetch queued successfully",
jobId: job.id,
host: {
id: host.id,
friendlyName: host.friendly_name,
apiId: host.api_id,
},
});
} catch (error) {
console.error("Force fetch report error:", error);
res.status(500).json({ error: "Failed to fetch report" });
}
},
);
// Toggle agent auto-update setting
router.patch(
"/:hostId/auto-update",
@@ -1491,66 +1430,6 @@ router.patch(
},
);
// Force agent update for specific host
router.post(
"/:hostId/force-agent-update",
authenticateToken,
requireManageHosts,
async (req, res) => {
try {
const { hostId } = req.params;
// Get host to verify it exists
const host = await prisma.hosts.findUnique({
where: { id: hostId },
});
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
// Get the agent-commands queue
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
if (!queue) {
return res.status(500).json({
error: "Queue not available",
});
}
// Add job to queue
const job = await queue.add(
"update_agent",
{
api_id: host.api_id,
type: "update_agent",
},
{
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
},
);
res.json({
success: true,
message: "Agent update queued successfully",
jobId: job.id,
host: {
id: host.id,
friendlyName: host.friendly_name,
apiId: host.api_id,
},
});
} catch (error) {
console.error("Force agent update error:", error);
res.status(500).json({ error: "Failed to force agent update" });
}
},
);
// Serve the installation script (requires API authentication)
router.get("/install", async (req, res) => {
try {

View File

@@ -13,8 +13,6 @@ router.post("/docker", async (req, res) => {
const {
containers,
images,
volumes,
networks,
updates,
daemon_info: _daemon_info,
hostname,
@@ -51,8 +49,6 @@ router.post("/docker", async (req, res) => {
let containersProcessed = 0;
let imagesProcessed = 0;
let volumesProcessed = 0;
let networksProcessed = 0;
let updatesProcessed = 0;
// Process containers
@@ -173,114 +169,6 @@ router.post("/docker", async (req, res) => {
}
}
// Process volumes
if (volumes && Array.isArray(volumes)) {
console.log(`[Docker Integration] Processing ${volumes.length} volumes`);
for (const volumeData of volumes) {
await prisma.docker_volumes.upsert({
where: {
host_id_volume_id: {
host_id: host.id,
volume_id: volumeData.volume_id,
},
},
update: {
name: volumeData.name,
driver: volumeData.driver || "local",
mountpoint: volumeData.mountpoint || null,
renderer: volumeData.renderer || null,
scope: volumeData.scope || "local",
labels: volumeData.labels || null,
options: volumeData.options || null,
size_bytes: volumeData.size_bytes
? BigInt(volumeData.size_bytes)
: null,
ref_count: volumeData.ref_count || 0,
updated_at: now,
last_checked: now,
},
create: {
id: uuidv4(),
host_id: host.id,
volume_id: volumeData.volume_id,
name: volumeData.name,
driver: volumeData.driver || "local",
mountpoint: volumeData.mountpoint || null,
renderer: volumeData.renderer || null,
scope: volumeData.scope || "local",
labels: volumeData.labels || null,
options: volumeData.options || null,
size_bytes: volumeData.size_bytes
? BigInt(volumeData.size_bytes)
: null,
ref_count: volumeData.ref_count || 0,
created_at: parseDate(volumeData.created_at),
updated_at: now,
},
});
volumesProcessed++;
}
}
// Process networks
if (networks && Array.isArray(networks)) {
console.log(
`[Docker Integration] Processing ${networks.length} networks`,
);
for (const networkData of networks) {
await prisma.docker_networks.upsert({
where: {
host_id_network_id: {
host_id: host.id,
network_id: networkData.network_id,
},
},
update: {
name: networkData.name,
driver: networkData.driver,
scope: networkData.scope || "local",
ipv6_enabled: networkData.ipv6_enabled || false,
internal: networkData.internal || false,
attachable:
networkData.attachable !== undefined
? networkData.attachable
: true,
ingress: networkData.ingress || false,
config_only: networkData.config_only || false,
labels: networkData.labels || null,
ipam: networkData.ipam || null,
container_count: networkData.container_count || 0,
updated_at: now,
last_checked: now,
},
create: {
id: uuidv4(),
host_id: host.id,
network_id: networkData.network_id,
name: networkData.name,
driver: networkData.driver,
scope: networkData.scope || "local",
ipv6_enabled: networkData.ipv6_enabled || false,
internal: networkData.internal || false,
attachable:
networkData.attachable !== undefined
? networkData.attachable
: true,
ingress: networkData.ingress || false,
config_only: networkData.config_only || false,
labels: networkData.labels || null,
ipam: networkData.ipam || null,
container_count: networkData.container_count || 0,
created_at: networkData.created_at
? parseDate(networkData.created_at)
: null,
updated_at: now,
},
});
networksProcessed++;
}
}
// Process updates
if (updates && Array.isArray(updates)) {
console.log(`[Docker Integration] Processing ${updates.length} updates`);
@@ -331,15 +219,13 @@ router.post("/docker", async (req, res) => {
}
console.log(
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${volumesProcessed} volumes, ${networksProcessed} networks, ${updatesProcessed} updates`,
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
);
res.json({
message: "Docker data collected successfully",
containers_received: containersProcessed,
images_received: imagesProcessed,
volumes_received: volumesProcessed,
networks_received: networksProcessed,
updates_found: updatesProcessed,
});
} catch (error) {

View File

@@ -261,10 +261,8 @@ router.post(
body("username").notEmpty().withMessage("Username is required"),
body("token")
.isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 characters"),
body("token")
.matches(/^[A-Z0-9]{6}$/)
.withMessage("Token must be 6 alphanumeric characters"),
.withMessage("Token must be 6 digits"),
body("token").isNumeric().withMessage("Token must contain only numbers"),
],
async (req, res) => {
try {

View File

@@ -1,105 +0,0 @@
const express = require("express");
const { getPrismaClient } = require("../config/prisma");
const { authenticateToken } = require("../middleware/auth");
const router = express.Router();
const prisma = getPrismaClient();
/**
* GET /api/v1/user/preferences
* Get current user's preferences (theme and color theme)
*/
router.get("/", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const user = await prisma.users.findUnique({
where: { id: userId },
select: {
theme_preference: true,
color_theme: true,
},
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({
theme_preference: user.theme_preference || "dark",
color_theme: user.color_theme || "cyber_blue",
});
} catch (error) {
console.error("Error fetching user preferences:", error);
res.status(500).json({ error: "Failed to fetch user preferences" });
}
});
/**
* PATCH /api/v1/user/preferences
* Update current user's preferences
*/
router.patch("/", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const { theme_preference, color_theme } = req.body;
// Validate inputs
const updateData = {};
if (theme_preference !== undefined) {
if (!["light", "dark"].includes(theme_preference)) {
return res.status(400).json({
error: "Invalid theme preference. Must be 'light' or 'dark'",
});
}
updateData.theme_preference = theme_preference;
}
if (color_theme !== undefined) {
const validColorThemes = [
"default",
"cyber_blue",
"neon_purple",
"matrix_green",
"ocean_blue",
"sunset_gradient",
];
if (!validColorThemes.includes(color_theme)) {
return res.status(400).json({
error: `Invalid color theme. Must be one of: ${validColorThemes.join(", ")}`,
});
}
updateData.color_theme = color_theme;
}
if (Object.keys(updateData).length === 0) {
return res
.status(400)
.json({ error: "No preferences provided to update" });
}
updateData.updated_at = new Date();
const updatedUser = await prisma.users.update({
where: { id: userId },
data: updateData,
select: {
theme_preference: true,
color_theme: true,
},
});
res.json({
message: "Preferences updated successfully",
preferences: {
theme_preference: updatedUser.theme_preference,
color_theme: updatedUser.color_theme,
},
});
} catch (error) {
console.error("Error updating user preferences:", error);
res.status(500).json({ error: "Failed to update user preferences" });
}
});
module.exports = router;

View File

@@ -70,7 +70,6 @@ const integrationRoutes = require("./routes/integrationRoutes");
const wsRoutes = require("./routes/wsRoutes");
const agentVersionRoutes = require("./routes/agentVersionRoutes");
const metricsRoutes = require("./routes/metricsRoutes");
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
const { initSettings } = require("./services/settingsService");
const { queueManager } = require("./services/automation");
const { authenticateToken, requireAdmin } = require("./middleware/auth");
@@ -387,7 +386,6 @@ app.use(
"Authorization",
"Cookie",
"X-Requested-With",
"X-Device-ID", // Allow device ID header for TFA remember-me functionality
],
}),
);
@@ -479,7 +477,6 @@ app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
app.use(`/api/${apiVersion}/ws`, wsRoutes);
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
// Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null;
@@ -559,6 +556,299 @@ app.use(`/bullboard`, (req, res, next) => {
return res.status(503).json({ error: "Bull Board not initialized yet" });
});
/*
// OLD MIDDLEWARE - REMOVED FOR SIMPLIFICATION - DO NOT USE
if (false) {
const sessionId = req.cookies["bull-board-session"];
console.log("Bull Board API call - Session ID:", sessionId ? "present" : "missing");
console.log("Bull Board API call - Cookies:", req.cookies);
console.log("Bull Board API call - Bull Board token cookie:", req.cookies["bull-board-token"] ? "present" : "missing");
console.log("Bull Board API call - Query token:", req.query.token ? "present" : "missing");
console.log("Bull Board API call - Auth header:", req.headers.authorization ? "present" : "missing");
console.log("Bull Board API call - Origin:", req.headers.origin || "missing");
console.log("Bull Board API call - Referer:", req.headers.referer || "missing");
// Check if we have any authentication method available
const hasSession = !!sessionId;
const hasTokenCookie = !!req.cookies["bull-board-token"];
const hasQueryToken = !!req.query.token;
const hasAuthHeader = !!req.headers.authorization;
const hasReferer = !!req.headers.referer;
console.log("Bull Board API call - Auth methods available:", {
session: hasSession,
tokenCookie: hasTokenCookie,
queryToken: hasQueryToken,
authHeader: hasAuthHeader,
referer: hasReferer
});
// Check for valid session first
if (sessionId) {
const session = bullBoardSessions.get(sessionId);
console.log("Bull Board API call - Session found:", !!session);
if (session && Date.now() - session.timestamp < 3600000) {
// Valid session, extend it
session.timestamp = Date.now();
console.log("Bull Board API call - Using existing session, proceeding");
return next();
} else if (session) {
// Expired session, remove it
console.log("Bull Board API call - Session expired, removing");
bullBoardSessions.delete(sessionId);
}
}
// No valid session, check for token as fallback
let token = req.query.token;
if (!token && req.headers.authorization) {
token = req.headers.authorization.replace("Bearer ", "");
}
if (!token && req.cookies["bull-board-token"]) {
token = req.cookies["bull-board-token"];
}
// For API calls, also check if the token is in the referer URL
// This handles cases where the main page hasn't set the cookie yet
if (!token && req.headers.referer) {
try {
const refererUrl = new URL(req.headers.referer);
const refererToken = refererUrl.searchParams.get('token');
if (refererToken) {
token = refererToken;
console.log("Bull Board API call - Token found in referer URL:", refererToken.substring(0, 20) + "...");
} else {
console.log("Bull Board API call - No token found in referer URL");
// If no token in referer and no session, return 401 with redirect info
if (!sessionId) {
console.log("Bull Board API call - No authentication available, returning 401");
return res.status(401).json({
error: "Authentication required",
message: "Please refresh the page to re-authenticate"
});
}
}
} catch (error) {
console.log("Bull Board API call - Error parsing referer URL:", error.message);
}
}
if (token) {
console.log("Bull Board API call - Token found, authenticating");
// Add token to headers for authentication
req.headers.authorization = `Bearer ${token}`;
// Authenticate the user
return authenticateToken(req, res, (err) => {
if (err) {
console.log("Bull Board API call - Token authentication failed");
return res.status(401).json({ error: "Authentication failed" });
}
return requireAdmin(req, res, (adminErr) => {
if (adminErr) {
console.log("Bull Board API call - Admin access required");
return res.status(403).json({ error: "Admin access required" });
}
console.log("Bull Board API call - Token authentication successful");
return next();
});
});
}
// No valid session or token for API calls, deny access
console.log("Bull Board API call - No valid session or token, denying access");
return res.status(401).json({ error: "Valid Bull Board session or token required" });
}
// Check for bull-board-session cookie first
const sessionId = req.cookies["bull-board-session"];
if (sessionId) {
const session = bullBoardSessions.get(sessionId);
if (session && Date.now() - session.timestamp < 3600000) {
// 1 hour
// Valid session, extend it
session.timestamp = Date.now();
return next();
} else if (session) {
// Expired session, remove it
bullBoardSessions.delete(sessionId);
}
}
// No valid session, check for token
let token = req.query.token;
if (!token && req.headers.authorization) {
token = req.headers.authorization.replace("Bearer ", "");
}
if (!token && req.cookies["bull-board-token"]) {
token = req.cookies["bull-board-token"];
}
// If no token, deny access
if (!token) {
return res.status(401).json({ error: "Access token required" });
}
// Add token to headers for authentication
req.headers.authorization = `Bearer ${token}`;
// Authenticate the user
return authenticateToken(req, res, (err) => {
if (err) {
return res.status(401).json({ error: "Authentication failed" });
}
return requireAdmin(req, res, (adminErr) => {
if (adminErr) {
return res.status(403).json({ error: "Admin access required" });
}
// Authentication successful - create a session
const newSessionId = require("node:crypto")
.randomBytes(32)
.toString("hex");
bullBoardSessions.set(newSessionId, {
timestamp: Date.now(),
userId: req.user.id,
});
// Set session cookie with proper configuration for domain access
const isHttps = process.env.NODE_ENV === "production" || process.env.SERVER_PROTOCOL === "https";
const cookieOptions = {
httpOnly: true,
secure: isHttps,
maxAge: 3600000, // 1 hour
path: "/", // Set path to root so it's available for all Bull Board requests
};
// Configure sameSite based on protocol and environment
if (isHttps) {
cookieOptions.sameSite = "none"; // Required for HTTPS cross-origin
} else {
cookieOptions.sameSite = "lax"; // Better for HTTP same-origin
}
res.cookie("bull-board-session", newSessionId, cookieOptions);
// Clean up old sessions periodically
if (bullBoardSessions.size > 100) {
const now = Date.now();
for (const [sid, session] of bullBoardSessions.entries()) {
if (now - session.timestamp > 3600000) {
bullBoardSessions.delete(sid);
}
}
}
return next();
});
});
});
*/
// Second middleware block - COMMENTED OUT - using simplified version above instead
/*
app.use(`/bullboard`, (req, res, next) => {
if (bullBoardRouter) {
// If this is the main Bull Board page (not an API call), inject the token and create session
if (!req.path.includes("/api/") && !req.path.includes("/static/") && req.path === "/bullboard") {
const token = req.query.token;
console.log("Bull Board main page - Token:", token ? "present" : "missing");
console.log("Bull Board main page - Query params:", req.query);
console.log("Bull Board main page - Origin:", req.headers.origin || "missing");
console.log("Bull Board main page - Referer:", req.headers.referer || "missing");
console.log("Bull Board main page - Cookies:", req.cookies);
if (token) {
// Authenticate the user and create a session immediately on page load
req.headers.authorization = `Bearer ${token}`;
return authenticateToken(req, res, (err) => {
if (err) {
console.log("Bull Board main page - Token authentication failed");
return res.status(401).json({ error: "Authentication failed" });
}
return requireAdmin(req, res, (adminErr) => {
if (adminErr) {
console.log("Bull Board main page - Admin access required");
return res.status(403).json({ error: "Admin access required" });
}
console.log("Bull Board main page - Token authentication successful, creating session");
// Create a Bull Board session immediately
const newSessionId = require("node:crypto")
.randomBytes(32)
.toString("hex");
bullBoardSessions.set(newSessionId, {
timestamp: Date.now(),
userId: req.user.id,
});
// Set session cookie with proper configuration for domain access
const sessionCookieOptions = {
httpOnly: true,
secure: false, // Always false for HTTP
maxAge: 3600000, // 1 hour
path: "/", // Set path to root so it's available for all Bull Board requests
sameSite: "lax", // Always lax for HTTP
};
res.cookie("bull-board-session", newSessionId, sessionCookieOptions);
console.log("Bull Board main page - Session created:", newSessionId);
console.log("Bull Board main page - Cookie options:", sessionCookieOptions);
// Also set a token cookie for API calls as a fallback
const tokenCookieOptions = {
httpOnly: false, // Allow JavaScript to access it
secure: false, // Always false for HTTP
maxAge: 3600000, // 1 hour
path: "/", // Set path to root for broader compatibility
sameSite: "lax", // Always lax for HTTP
};
res.cookie("bull-board-token", token, tokenCookieOptions);
console.log("Bull Board main page - Token cookie also set for API fallback");
// Clean up old sessions periodically
if (bullBoardSessions.size > 100) {
const now = Date.now();
for (const [sid, session] of bullBoardSessions.entries()) {
if (now - session.timestamp > 3600000) {
bullBoardSessions.delete(sid);
}
}
}
// Now proceed to serve the Bull Board page
return bullBoardRouter(req, res, next);
});
});
} else {
console.log("Bull Board main page - No token provided, checking for existing session");
// Check if we have an existing session
const sessionId = req.cookies["bull-board-session"];
if (sessionId) {
const session = bullBoardSessions.get(sessionId);
if (session && Date.now() - session.timestamp < 3600000) {
console.log("Bull Board main page - Using existing session");
// Extend session
session.timestamp = Date.now();
return bullBoardRouter(req, res, next);
} else if (session) {
console.log("Bull Board main page - Session expired, removing");
bullBoardSessions.delete(sessionId);
}
}
console.log("Bull Board main page - No valid session, denying access");
return res.status(401).json({ error: "Access token required" });
}
}
return bullBoardRouter(req, res, next);
}
return res.status(503).json({ error: "Bull Board not initialized yet" });
});
*/
// Error handler specifically for Bull Board routes
app.use("/bullboard", (err, req, res, _next) => {
console.error("Bull Board error on", req.method, req.url);

View File

@@ -176,15 +176,6 @@ function pushSettingsUpdate(apiId, newInterval) {
);
}
function pushUpdateAgent(apiId) {
const ws = apiIdToSocket.get(apiId);
safeSend(ws, JSON.stringify({ type: "update_agent" }));
}
function getConnectionByApiId(apiId) {
return apiIdToSocket.get(apiId);
}
function pushUpdateNotification(apiId, updateInfo) {
const ws = apiIdToSocket.get(apiId);
if (ws && ws.readyState === WebSocket.OPEN) {
@@ -339,12 +330,10 @@ module.exports = {
broadcastSettingsUpdate,
pushReportNow,
pushSettingsUpdate,
pushUpdateAgent,
pushUpdateNotification,
pushUpdateNotificationToAll,
// Expose read-only view of connected agents
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
getConnectionByApiId,
isConnected: (apiId) => {
const ws = apiIdToSocket.get(apiId);
return !!ws && ws.readyState === WebSocket.OPEN;

View File

@@ -1,343 +0,0 @@
const { prisma } = require("./shared/prisma");
const https = require("node:https");
const http = require("node:http");
const { v4: uuidv4 } = require("uuid");
/**
* Docker Image Update Check Automation
* Checks for Docker image updates by comparing local digests with remote registry digests
*/
class DockerImageUpdateCheck {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "docker-image-update-check";
}
/**
* Get remote digest from Docker registry using HEAD request
* Supports Docker Hub, GHCR, and other OCI-compliant registries
*/
async getRemoteDigest(imageName, tag = "latest") {
return new Promise((resolve, reject) => {
// Parse image name to determine registry
const registryInfo = this.parseImageName(imageName);
// Construct manifest URL
const manifestPath = `/v2/${registryInfo.repository}/manifests/${tag}`;
const options = {
hostname: registryInfo.registry,
path: manifestPath,
method: "HEAD",
headers: {
Accept:
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json",
"User-Agent": "PatchMon/1.0",
},
};
// Add authentication token for Docker Hub if needed
if (
registryInfo.registry === "registry-1.docker.io" &&
registryInfo.isPublic
) {
// For anonymous public images, we may need to get an auth token first
// For now, try without auth (works for public images)
}
// Choose HTTP or HTTPS
const client = registryInfo.isSecure ? https : http;
const req = client.request(options, (res) => {
if (res.statusCode === 401 || res.statusCode === 403) {
// Authentication required - skip for now (would need to implement auth)
return reject(
new Error(`Authentication required for ${imageName}:${tag}`),
);
}
if (res.statusCode !== 200) {
return reject(
new Error(
`Registry returned status ${res.statusCode} for ${imageName}:${tag}`,
),
);
}
// Get digest from Docker-Content-Digest header
const digest = res.headers["docker-content-digest"];
if (!digest) {
return reject(
new Error(
`No Docker-Content-Digest header for ${imageName}:${tag}`,
),
);
}
// Clean up digest (remove sha256: prefix if present)
const cleanDigest = digest.startsWith("sha256:")
? digest.substring(7)
: digest;
resolve(cleanDigest);
});
req.on("error", (error) => {
reject(error);
});
req.setTimeout(10000, () => {
req.destroy();
reject(new Error(`Timeout getting digest for ${imageName}:${tag}`));
});
req.end();
});
}
/**
* Parse image name to extract registry, repository, and determine if secure
*/
parseImageName(imageName) {
let registry = "registry-1.docker.io";
let repository = imageName;
const isSecure = true;
let isPublic = true;
// Handle explicit registries (ghcr.io, quay.io, etc.)
if (imageName.includes("/")) {
const parts = imageName.split("/");
const firstPart = parts[0];
// Check for known registries
if (firstPart.includes(".") || firstPart === "localhost") {
registry = firstPart;
repository = parts.slice(1).join("/");
isPublic = false; // Assume private registries need auth for now
} else {
// Docker Hub - registry-1.docker.io
repository = imageName;
}
}
// Docker Hub official images (no namespace)
if (!repository.includes("/")) {
repository = `library/${repository}`;
}
return {
registry,
repository,
isSecure,
isPublic,
};
}
/**
* Process Docker image update check job
*/
async process(_job) {
const startTime = Date.now();
console.log("🐳 Starting Docker image update check...");
try {
// Get all Docker images that have a digest and repository
const images = await prisma.docker_images.findMany({
where: {
digest: {
not: null,
},
repository: {
not: null,
},
},
include: {
docker_image_updates: true,
},
});
console.log(`📦 Found ${images.length} images to check for updates`);
let checkedCount = 0;
let updateCount = 0;
let errorCount = 0;
const errors = [];
// Process images in batches to avoid overwhelming the API
const batchSize = 10;
for (let i = 0; i < images.length; i += batchSize) {
const batch = images.slice(i, i + batchSize);
// Process batch concurrently with Promise.allSettled for error tolerance
const _results = await Promise.allSettled(
batch.map(async (image) => {
try {
checkedCount++;
// Skip local images (no digest means they're local)
if (!image.digest || image.digest.trim() === "") {
return { image, skipped: true, reason: "No digest" };
}
// Get clean digest (remove sha256: prefix if present)
const localDigest = image.digest.startsWith("sha256:")
? image.digest.substring(7)
: image.digest;
// Get remote digest from registry
const remoteDigest = await this.getRemoteDigest(
image.repository,
image.tag || "latest",
);
// Compare digests
if (localDigest !== remoteDigest) {
console.log(
`🔄 Update found: ${image.repository}:${image.tag} (local: ${localDigest.substring(0, 12)}..., remote: ${remoteDigest.substring(0, 12)}...)`,
);
// Store digest info in changelog_url field as JSON
const digestInfo = JSON.stringify({
method: "digest_comparison",
current_digest: localDigest,
available_digest: remoteDigest,
checked_at: new Date().toISOString(),
});
// Upsert the update record
await prisma.docker_image_updates.upsert({
where: {
image_id_available_tag: {
image_id: image.id,
available_tag: image.tag || "latest",
},
},
update: {
updated_at: new Date(),
changelog_url: digestInfo,
severity: "digest_changed",
},
create: {
id: uuidv4(),
image_id: image.id,
current_tag: image.tag || "latest",
available_tag: image.tag || "latest",
severity: "digest_changed",
changelog_url: digestInfo,
updated_at: new Date(),
},
});
// Update last_checked timestamp on image
await prisma.docker_images.update({
where: { id: image.id },
data: { last_checked: new Date() },
});
updateCount++;
return { image, updated: true };
} else {
// No update - still update last_checked
await prisma.docker_images.update({
where: { id: image.id },
data: { last_checked: new Date() },
});
// Remove existing update record if digest matches now
const existingUpdate = image.docker_image_updates?.find(
(u) => u.available_tag === (image.tag || "latest"),
);
if (existingUpdate) {
await prisma.docker_image_updates.delete({
where: { id: existingUpdate.id },
});
}
return { image, updated: false };
}
} catch (error) {
errorCount++;
const errorMsg = `Error checking ${image.repository}:${image.tag}: ${error.message}`;
errors.push(errorMsg);
console.error(`${errorMsg}`);
// Still update last_checked even on error
try {
await prisma.docker_images.update({
where: { id: image.id },
data: { last_checked: new Date() },
});
} catch (_updateError) {
// Ignore update errors
}
return { image, error: error.message };
}
}),
);
// Log batch progress
if (i + batchSize < images.length) {
console.log(
`⏳ Processed ${Math.min(i + batchSize, images.length)}/${images.length} images...`,
);
}
// Small delay between batches to be respectful to registries
if (i + batchSize < images.length) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
const executionTime = Date.now() - startTime;
console.log(
`✅ Docker image update check completed in ${executionTime}ms - Checked: ${checkedCount}, Updates: ${updateCount}, Errors: ${errorCount}`,
);
return {
success: true,
checked: checkedCount,
updates: updateCount,
errors: errorCount,
executionTime,
errorDetails: errors,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ Docker image update check failed after ${executionTime}ms:`,
error.message,
);
throw error;
}
}
/**
* Schedule recurring Docker image update check (daily at 2 AM)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"docker-image-update-check",
{},
{
repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
jobId: "docker-image-update-check-recurring",
},
);
console.log("✅ Docker image update check scheduled");
return job;
}
/**
* Trigger manual Docker image update check
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"docker-image-update-check-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual Docker image update check triggered");
return job;
}
}
module.exports = DockerImageUpdateCheck;

View File

@@ -2,7 +2,6 @@ const { Queue, Worker } = require("bullmq");
const { redis, redisConnection } = require("./shared/redis");
const { prisma } = require("./shared/prisma");
const agentWs = require("../agentWs");
const { v4: uuidv4 } = require("uuid");
// Import automation classes
const GitHubUpdateCheck = require("./githubUpdateCheck");
@@ -10,7 +9,6 @@ const SessionCleanup = require("./sessionCleanup");
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
const DockerImageUpdateCheck = require("./dockerImageUpdateCheck");
const MetricsReporting = require("./metricsReporting");
// Queue names
@@ -20,7 +18,6 @@ const QUEUE_NAMES = {
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
DOCKER_IMAGE_UPDATE_CHECK: "docker-image-update-check",
METRICS_REPORTING: "metrics-reporting",
AGENT_COMMANDS: "agent-commands",
};
@@ -100,8 +97,6 @@ class QueueManager {
new OrphanedPackageCleanup(this);
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
new DockerInventoryCleanup(this);
this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK] =
new DockerImageUpdateCheck(this);
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
this,
);
@@ -172,15 +167,6 @@ class QueueManager {
workerOptions,
);
// Docker Image Update Check Worker
this.workers[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK] = new Worker(
QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK,
this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].process.bind(
this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK],
),
workerOptions,
);
// Metrics Reporting Worker
this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
QUEUE_NAMES.METRICS_REPORTING,
@@ -197,87 +183,15 @@ class QueueManager {
const { api_id, type } = job.data;
console.log(`Processing agent command: ${type} for ${api_id}`);
// Log job to job_history
let historyRecord = null;
try {
const host = await prisma.hosts.findUnique({
where: { api_id },
select: { id: true },
});
if (host) {
historyRecord = await prisma.job_history.create({
data: {
id: uuidv4(),
job_id: job.id,
queue_name: QUEUE_NAMES.AGENT_COMMANDS,
job_name: type,
host_id: host.id,
api_id: api_id,
status: "active",
attempt_number: job.attemptsMade + 1,
created_at: new Date(),
updated_at: new Date(),
},
});
console.log(`📝 Logged job to job_history: ${job.id} (${type})`);
}
} catch (error) {
console.error("Failed to log job to job_history:", error);
}
try {
// Send command via WebSocket based on type
if (type === "report_now") {
agentWs.pushReportNow(api_id);
} else if (type === "settings_update") {
// For settings update, we need additional data
const { update_interval } = job.data;
agentWs.pushSettingsUpdate(api_id, update_interval);
} else if (type === "update_agent") {
// Force agent to update by sending WebSocket command
const ws = agentWs.getConnectionByApiId(api_id);
if (ws && ws.readyState === 1) {
// WebSocket.OPEN
agentWs.pushUpdateAgent(api_id);
console.log(`✅ Update command sent to agent ${api_id}`);
} else {
console.error(`❌ Agent ${api_id} is not connected`);
throw new Error(
`Agent ${api_id} is not connected. Cannot send update command.`,
);
}
} else {
console.error(`Unknown agent command type: ${type}`);
}
// Update job history to completed
if (historyRecord) {
await prisma.job_history.updateMany({
where: { job_id: job.id },
data: {
status: "completed",
completed_at: new Date(),
updated_at: new Date(),
},
});
console.log(`✅ Marked job as completed in job_history: ${job.id}`);
}
} catch (error) {
// Update job history to failed
if (historyRecord) {
await prisma.job_history.updateMany({
where: { job_id: job.id },
data: {
status: "failed",
error_message: error.message,
completed_at: new Date(),
updated_at: new Date(),
},
});
console.log(`❌ Marked job as failed in job_history: ${job.id}`);
}
throw error;
// Send command via WebSocket based on type
if (type === "report_now") {
agentWs.pushReportNow(api_id);
} else if (type === "settings_update") {
// For settings update, we need additional data
const { update_interval } = job.data;
agentWs.pushSettingsUpdate(api_id, update_interval);
} else {
console.error(`Unknown agent command type: ${type}`);
}
},
workerOptions,
@@ -307,7 +221,6 @@ class QueueManager {
console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`);
});
}
console.log("✅ Queue events initialized");
}
@@ -320,7 +233,6 @@ class QueueManager {
await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].schedule();
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
}
@@ -351,12 +263,6 @@ class QueueManager {
].triggerManual();
}
async triggerDockerImageUpdateCheck() {
return this.automations[
QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK
].triggerManual();
}
async triggerMetricsReporting() {
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
}

View File

@@ -1,179 +0,0 @@
/**
* Docker-related utility functions
*/
/**
* Generate a registry link for a Docker image based on its repository and source
* Inspired by diun's registry link generation
* @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo")
* @param {string} source - The detected source (github, gitlab, docker-hub, etc.)
* @returns {string|null} - The URL to the registry page, or null if unknown
*/
function generateRegistryLink(repository, source) {
if (!repository) {
return null;
}
// Parse the domain and path from the repository
const parts = repository.split("/");
let domain = "";
let path = "";
// Check if repository has a domain (contains a dot)
if (parts[0].includes(".") || parts[0].includes(":")) {
domain = parts[0];
path = parts.slice(1).join("/");
} else {
// No domain means Docker Hub
domain = "docker.io";
path = repository;
}
switch (source) {
case "docker-hub":
case "docker.io": {
// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images
// Official images are those without a namespace (e.g., "postgres" not "user/postgres")
// or explicitly prefixed with "library/"
if (path.startsWith("library/")) {
const cleanPath = path.replace("library/", "");
return `https://hub.docker.com/_/${cleanPath}`;
}
// Check if it's an official image (single part, no slash after removing library/)
if (!path.includes("/")) {
return `https://hub.docker.com/_/${path}`;
}
// Regular user/org image
return `https://hub.docker.com/r/${path}`;
}
case "github":
case "ghcr.io": {
// GitHub Container Registry
// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package}
// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package}
if (domain === "ghcr.io" && path) {
const pathParts = path.split("/");
if (pathParts.length === 2) {
// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package
// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package}
// Actually, for 2 parts it's owner/package, and repo is same as owner typically
const owner = pathParts[0];
const packageName = pathParts[1];
return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`;
} else if (pathParts.length >= 3) {
// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package
const owner = pathParts[0];
const repo = pathParts[1];
const packageName = pathParts.slice(2).join("/");
return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`;
}
}
// Legacy GitHub Packages
if (domain === "docker.pkg.github.com" && path) {
const pathParts = path.split("/");
if (pathParts.length >= 1) {
return `https://github.com/${pathParts[0]}/packages`;
}
}
return null;
}
case "gitlab":
case "registry.gitlab.com": {
// GitLab Container Registry: https://gitlab.com/{path}/container_registry
if (path) {
return `https://gitlab.com/${path}/container_registry`;
}
return null;
}
case "google":
case "gcr.io": {
// Google Container Registry: https://gcr.io/{path}
if (domain.includes("gcr.io") || domain.includes("pkg.dev")) {
return `https://console.cloud.google.com/gcr/images/${path}`;
}
return null;
}
case "quay":
case "quay.io": {
// Quay.io: https://quay.io/repository/{path}
if (path) {
return `https://quay.io/repository/${path}`;
}
return null;
}
case "redhat":
case "registry.access.redhat.com": {
// Red Hat: https://access.redhat.com/containers/#/registry.access.redhat.com/{path}
if (path) {
return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`;
}
return null;
}
case "azure":
case "azurecr.io": {
// Azure Container Registry - link to portal
// Format: {registry}.azurecr.io/{repository}
if (domain.includes("azurecr.io")) {
const registryName = domain.split(".")[0];
return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`;
}
return null;
}
case "aws":
case "amazonaws.com": {
// AWS ECR - link to console
// Format: {account}.dkr.ecr.{region}.amazonaws.com/{repository}
if (domain.includes("amazonaws.com")) {
const domainParts = domain.split(".");
const region = domainParts[3]; // Extract region
return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`;
}
return null;
}
case "private":
// For private registries, try to construct a basic URL
if (domain) {
return `https://${domain}`;
}
return null;
default:
return null;
}
}
/**
* Get a user-friendly display name for a registry source
* @param {string} source - The source identifier
* @returns {string} - Human-readable source name
*/
function getSourceDisplayName(source) {
const sourceNames = {
"docker-hub": "Docker Hub",
github: "GitHub",
gitlab: "GitLab",
google: "Google",
quay: "Quay.io",
redhat: "Red Hat",
azure: "Azure",
aws: "AWS ECR",
private: "Private Registry",
local: "Local",
unknown: "Unknown",
};
return sourceNames[source] || source;
}
module.exports = {
generateRegistryLink,
getSourceDisplayName,
};

View File

@@ -84,20 +84,21 @@ function parse_expiration(expiration_string) {
* Generate device fingerprint from request data
*/
function generate_device_fingerprint(req) {
// Use the X-Device-ID header from frontend (unique per browser profile/localStorage)
const deviceId = req.get("x-device-id");
const components = [
req.get("user-agent") || "",
req.get("accept-language") || "",
req.get("accept-encoding") || "",
req.ip || "",
];
if (deviceId) {
// Hash the device ID for consistent storage format
return crypto
.createHash("sha256")
.update(deviceId)
.digest("hex")
.substring(0, 32);
}
// Create a simple hash of device characteristics
const fingerprint = crypto
.createHash("sha256")
.update(components.join("|"))
.digest("hex")
.substring(0, 32); // Use first 32 chars for storage efficiency
// No device ID - return null (user needs to provide device ID for remember-me)
return null;
return fingerprint;
}
/**

View File

@@ -48,7 +48,7 @@ WORKDIR /app/backend
RUN npm cache clean --force &&\
rm -rf node_modules ~/.npm /root/.npm &&\
npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0 &&\
PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
npm prune --omit=dev &&\
npm cache clean --force

View File

@@ -21,13 +21,9 @@ WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN echo "=== Starting npm install ===" &&\
npm cache clean --force &&\
RUN npm cache clean --force &&\
rm -rf node_modules ~/.npm /root/.npm &&\
echo "=== npm install ===" &&\
npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
echo "=== npm install completed ===" &&\
npm cache clean --force
npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0
COPY frontend/ ./

View File

@@ -1,6 +1,5 @@
server {
listen 3000;
listen [::]:3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;

View File

@@ -1,7 +1,7 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.3.2",
"version": "1.3.1",
"license": "AGPL-3.0",
"type": "module",
"scripts": {
@@ -27,7 +27,8 @@
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1"
"react-router-dom": "^6.30.1",
"trianglify": "^4.1.1"
},
"devDependencies": {
"@types/react": "^18.3.14",

View File

@@ -8,7 +8,6 @@ import SettingsLayout from "./components/SettingsLayout";
import { isAuthPhase } from "./constants/authPhases";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
import { SettingsProvider } from "./contexts/SettingsContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
@@ -29,8 +28,6 @@ const DockerContainerDetail = lazy(
);
const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail"));
const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail"));
const DockerVolumeDetail = lazy(() => import("./pages/docker/VolumeDetail"));
const DockerNetworkDetail = lazy(() => import("./pages/docker/NetworkDetail"));
const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
const Integrations = lazy(() => import("./pages/settings/Integrations"));
const Notifications = lazy(() => import("./pages/settings/Notifications"));
@@ -197,26 +194,6 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/docker/volumes/:id"
element={
<ProtectedRoute requirePermission="can_view_reports">
<Layout>
<DockerVolumeDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/docker/networks/:id"
element={
<ProtectedRoute requirePermission="can_view_reports">
<Layout>
<DockerNetworkDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/users"
element={
@@ -450,19 +427,17 @@ function AppRoutes() {
function App() {
return (
<AuthProvider>
<ThemeProvider>
<SettingsProvider>
<ColorThemeProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</ColorThemeProvider>
</SettingsProvider>
</ThemeProvider>
</AuthProvider>
<ThemeProvider>
<ColorThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</AuthProvider>
</ColorThemeProvider>
</ThemeProvider>
);
}

View File

@@ -28,6 +28,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react";
import { FaReddit, FaYoutube } from "react-icons/fa";
import { Link, useLocation, useNavigate } from "react-router-dom";
import trianglify from "trianglify";
import { useAuth } from "../contexts/AuthContext";
import { useColorTheme } from "../contexts/ColorThemeContext";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
@@ -236,93 +237,31 @@ const Layout = ({ children }) => {
navigate("/hosts?action=add");
};
// Generate clean radial gradient background with subtle triangular accents for dark mode
// Generate Trianglify background for dark mode
useEffect(() => {
const generateBackground = () => {
if (
!bgCanvasRef.current ||
!themeConfig?.login ||
!document.documentElement.classList.contains("dark")
bgCanvasRef.current &&
themeConfig?.login &&
document.documentElement.classList.contains("dark")
) {
return;
}
// Get current date as seed for daily variation
const today = new Date();
const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
const canvas = bgCanvasRef.current;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext("2d");
// Generate pattern with selected theme configuration
const pattern = trianglify({
width: window.innerWidth,
height: window.innerHeight,
cellSize: themeConfig.login.cellSize,
variance: themeConfig.login.variance,
seed: dateSeed,
xColors: themeConfig.login.xColors,
yColors: themeConfig.login.yColors,
});
// Get theme colors - pick first color from each palette
const xColors = themeConfig.login.xColors || [
"#667eea",
"#764ba2",
"#f093fb",
"#4facfe",
];
const yColors = themeConfig.login.yColors || [
"#667eea",
"#764ba2",
"#f093fb",
"#4facfe",
];
// Use date for daily color rotation
const today = new Date();
const seed =
today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
const random = (s) => {
const x = Math.sin(s) * 10000;
return x - Math.floor(x);
};
const color1 = xColors[Math.floor(random(seed) * xColors.length)];
const color2 = yColors[Math.floor(random(seed + 1000) * yColors.length)];
// Create clean radial gradient from center to bottom-right corner
const gradient = ctx.createRadialGradient(
canvas.width * 0.3, // Center slightly left
canvas.height * 0.3, // Center slightly up
0,
canvas.width * 0.5, // Expand to cover screen
canvas.height * 0.5,
Math.max(canvas.width, canvas.height) * 1.2,
);
// Subtle gradient with darker corners
gradient.addColorStop(0, color1);
gradient.addColorStop(0.6, color2);
gradient.addColorStop(1, "#0a0a0a"); // Very dark edges
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add subtle triangular shapes as accents across entire background
const cellSize = 180;
const cols = Math.ceil(canvas.width / cellSize) + 1;
const rows = Math.ceil(canvas.height / cellSize) + 1;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const idx = y * cols + x;
// Draw more triangles (less sparse)
if (random(seed + idx + 5000) > 0.4) {
const baseX =
x * cellSize + random(seed + idx * 3) * cellSize * 0.8;
const baseY =
y * cellSize + random(seed + idx * 3 + 100) * cellSize * 0.8;
const size = 50 + random(seed + idx * 4) * 100;
ctx.beginPath();
ctx.moveTo(baseX, baseY);
ctx.lineTo(baseX + size, baseY);
ctx.lineTo(baseX + size / 2, baseY - size * 0.866);
ctx.closePath();
// More visible white with slightly higher opacity
ctx.fillStyle = `rgba(255, 255, 255, ${0.05 + random(seed + idx * 5) * 0.08})`;
ctx.fill();
}
}
// Render to canvas
pattern.toCanvas(bgCanvasRef.current);
}
};

View File

@@ -1,8 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { useSettings } from "../contexts/SettingsContext";
import { isAuthReady } from "../constants/authPhases";
import { useAuth } from "../contexts/AuthContext";
import { settingsAPI } from "../utils/api";
const LogoProvider = ({ children }) => {
const { settings } = useSettings();
const { authPhase, isAuthenticated } = useAuth();
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
enabled: isAuthReady(authPhase, isAuthenticated()),
});
useEffect(() => {
// Use custom favicon or fallback to default

View File

@@ -1,6 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
import {
AlertCircle,
Image,
Palette,
RotateCcw,
Upload,
X,
} from "lucide-react";
import { useState } from "react";
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
import { settingsAPI } from "../../utils/api";
const BrandingTab = () => {
@@ -12,6 +20,7 @@ const BrandingTab = () => {
});
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
const [selectedLogoType, setSelectedLogoType] = useState("dark");
const { colorTheme, setColorTheme } = useColorTheme();
const queryClient = useQueryClient();
@@ -75,6 +84,22 @@ const BrandingTab = () => {
},
});
// Theme update mutation
const updateThemeMutation = useMutation({
mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
onSuccess: (_data, theme) => {
queryClient.invalidateQueries(["settings"]);
setColorTheme(theme);
},
onError: (error) => {
console.error("Update theme error:", error);
},
});
const handleThemeChange = (theme) => {
updateThemeMutation.mutate(theme);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -112,11 +137,93 @@ const BrandingTab = () => {
</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
Customize your PatchMon installation with custom logos and favicon.
These will be displayed throughout the application.
Customize your PatchMon installation with custom logos, favicon, and
color themes. These will be displayed throughout the application.
</p>
</div>
{/* Color Theme Selector */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center mb-4">
<Palette className="h-5 w-5 text-primary-600 mr-2" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Color Theme
</h3>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Choose a color theme that will be applied to the login page and
background areas throughout the app.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
const isSelected = colorTheme === themeKey;
const gradientColors = theme.login.xColors;
return (
<button
key={themeKey}
type="button"
onClick={() => handleThemeChange(themeKey)}
disabled={updateThemeMutation.isPending}
className={`relative p-4 rounded-lg border-2 transition-all ${
isSelected
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
>
{/* Theme Preview */}
<div
className="h-20 rounded-md mb-3 overflow-hidden"
style={{
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
}}
/>
{/* Theme Name */}
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
{theme.name}
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
aria-label="Selected theme"
>
<title>Selected</title>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</button>
);
})}
</div>
{updateThemeMutation.isPending && (
<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Updating theme...
</div>
)}
{updateThemeMutation.isError && (
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
Failed to update theme: {updateThemeMutation.error?.message}
</p>
</div>
)}
</div>
{/* Logo Section Header */}
<div className="flex items-center mb-4">
<Image className="h-5 w-5 text-primary-600 mr-2" />

View File

@@ -91,29 +91,10 @@ export const AuthProvider = ({ children }) => {
const login = async (username, password) => {
try {
// Get or generate device ID for TFA remember-me
let deviceId = localStorage.getItem("device_id");
if (!deviceId) {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
deviceId = crypto.randomUUID();
} else {
deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
(c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}
localStorage.setItem("device_id", deviceId);
}
const response = await fetch("/api/v1/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Device-ID": deviceId,
},
body: JSON.stringify({ username, password }),
});
@@ -138,9 +119,6 @@ export const AuthProvider = ({ children }) => {
setPermissions(userPermissions);
}
// Note: User preferences will be automatically fetched by ColorThemeContext
// when the component mounts, so no need to invalidate here
return { success: true };
} else {
// Handle HTTP error responses (like 500 CORS errors)
@@ -227,19 +205,8 @@ export const AuthProvider = ({ children }) => {
const data = await response.json();
if (response.ok) {
// Validate that we received user data with expected fields
if (!data.user || !data.user.id) {
console.error("Invalid user data in response:", data);
return {
success: false,
error: "Invalid response from server",
};
}
// Update both state and localStorage atomically
setUser(data.user);
localStorage.setItem("user", JSON.stringify(data.user));
return { success: true, user: data.user };
} else {
// Handle HTTP error responses (like 500 CORS errors)

View File

@@ -1,15 +1,4 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { userPreferencesAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
import { createContext, useContext, useEffect, useState } from "react";
const ColorThemeContext = createContext();
@@ -132,108 +121,62 @@ export const THEME_PRESETS = {
};
export const ColorThemeProvider = ({ children }) => {
const queryClient = useQueryClient();
const lastThemeRef = useRef(null);
// Use reactive authentication state from AuthContext
// This ensures the query re-enables when user logs in
const { user } = useAuth();
const isAuthenticated = !!user;
// Source of truth: Database (via userPreferences query)
// localStorage is only used as a temporary cache until DB loads
// Only fetch if user is authenticated to avoid 401 errors on login page
const { data: userPreferences, isLoading: preferencesLoading } = useQuery({
queryKey: ["userPreferences"],
queryFn: () => userPreferencesAPI.get().then((res) => res.data),
enabled: isAuthenticated, // Only run query if user is authenticated
retry: 2,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: true, // Refetch when user returns to tab
});
// Get theme from database (source of truth), fallback to user object from login, then localStorage cache, then default
// Memoize to prevent recalculation on every render
const colorThemeValue = useMemo(() => {
return (
userPreferences?.color_theme ||
user?.color_theme ||
localStorage.getItem("colorTheme") ||
"cyber_blue"
);
}, [userPreferences?.color_theme, user?.color_theme]);
// Only update state if the theme value actually changed (prevent loops)
const [colorTheme, setColorTheme] = useState(() => colorThemeValue);
const [colorTheme, setColorTheme] = useState("default");
const [isLoading, setIsLoading] = useState(true);
// Fetch theme from settings on mount
useEffect(() => {
// Only update if the value actually changed from what we last saw (prevent loops)
if (colorThemeValue !== lastThemeRef.current) {
setColorTheme(colorThemeValue);
lastThemeRef.current = colorThemeValue;
}
}, [colorThemeValue]);
const isLoading = preferencesLoading;
// Sync localStorage cache when DB data is available (for offline/performance)
useEffect(() => {
if (userPreferences?.color_theme) {
localStorage.setItem("colorTheme", userPreferences.color_theme);
}
}, [userPreferences?.color_theme]);
const updateColorTheme = useCallback(
async (theme) => {
// Store previous theme for potential revert
const previousTheme = colorTheme;
// Immediately update state for instant UI feedback
setColorTheme(theme);
lastThemeRef.current = theme;
// Also update localStorage cache
localStorage.setItem("colorTheme", theme);
// Save to backend (source of truth)
const fetchTheme = async () => {
try {
await userPreferencesAPI.update({ color_theme: theme });
// Check localStorage first for unauthenticated pages (login)
const cachedTheme = localStorage.getItem("colorTheme");
if (cachedTheme) {
setColorTheme(cachedTheme);
}
// Invalidate and refetch user preferences to ensure sync across tabs/browsers
await queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
// Try to fetch from API (will fail on login page, that's ok)
try {
const token = localStorage.getItem("token");
if (token) {
const response = await fetch("/api/v1/settings", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
if (data.color_theme) {
setColorTheme(data.color_theme);
localStorage.setItem("colorTheme", data.color_theme);
}
}
}
} catch (_apiError) {
// Silent fail - use cached or default theme
console.log("Could not fetch theme from API, using cached/default");
}
} catch (error) {
console.error("Failed to save color theme preference:", error);
// Revert to previous theme if save failed
setColorTheme(previousTheme);
lastThemeRef.current = previousTheme;
localStorage.setItem("colorTheme", previousTheme);
// Invalidate to refresh from DB
await queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
// Show error to user if possible (could add toast notification here)
throw error; // Re-throw so calling code can handle it
console.error("Error loading color theme:", error);
} finally {
setIsLoading(false);
}
},
[colorTheme, queryClient],
);
};
// Memoize themeConfig to prevent unnecessary re-renders
const themeConfig = useMemo(
() => THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
[colorTheme],
);
fetchTheme();
}, []);
// Memoize the context value to prevent unnecessary re-renders
const value = useMemo(
() => ({
colorTheme,
setColorTheme: updateColorTheme,
themeConfig,
isLoading,
}),
[colorTheme, themeConfig, isLoading, updateColorTheme],
);
const updateColorTheme = (theme) => {
setColorTheme(theme);
localStorage.setItem("colorTheme", theme);
};
const value = {
colorTheme,
setColorTheme: updateColorTheme,
themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
isLoading,
};
return (
<ColorThemeContext.Provider value={value}>

View File

@@ -1,45 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext } from "react";
import { isAuthReady } from "../constants/authPhases";
import { settingsAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const SettingsContext = createContext();
export const useSettings = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error("useSettings must be used within a SettingsProvider");
}
return context;
};
export const SettingsProvider = ({ children }) => {
const { authPhase, isAuthenticated } = useAuth();
const {
data: settings,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
refetchOnWindowFocus: false,
enabled: isAuthReady(authPhase, isAuthenticated()),
});
const value = {
settings,
isLoading,
error,
refetch,
};
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
};

View File

@@ -1,7 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useState } from "react";
import { userPreferencesAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const ThemeContext = createContext();
@@ -15,7 +12,7 @@ export const useTheme = () => {
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => {
// Check localStorage first for immediate render
// Check localStorage first, then system preference
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
return savedTheme;
@@ -27,30 +24,6 @@ export const ThemeProvider = ({ children }) => {
return "light";
});
// Use reactive authentication state from AuthContext
// This ensures the query re-enables when user logs in
const { user } = useAuth();
const isAuthenticated = !!user;
// Fetch user preferences from backend (only if authenticated)
const { data: userPreferences } = useQuery({
queryKey: ["userPreferences"],
queryFn: () => userPreferencesAPI.get().then((res) => res.data),
enabled: isAuthenticated, // Only run query if user is authenticated
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Sync with user preferences from backend or user object from login
useEffect(() => {
const preferredTheme =
userPreferences?.theme_preference || user?.theme_preference;
if (preferredTheme) {
setTheme(preferredTheme);
localStorage.setItem("theme", preferredTheme);
}
}, [userPreferences, user?.theme_preference]);
useEffect(() => {
// Apply theme to document
if (theme === "dark") {
@@ -63,17 +36,8 @@ export const ThemeProvider = ({ children }) => {
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = async () => {
const newTheme = theme === "light" ? "dark" : "light";
setTheme(newTheme);
// Save to backend
try {
await userPreferencesAPI.update({ theme_preference: newTheme });
} catch (error) {
console.error("Failed to save theme preference:", error);
// Theme is already set locally, so user still sees the change
}
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
};
const value = {

View File

@@ -1,5 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useState } from "react";
import { useSettings } from "./SettingsContext";
import { isAuthReady } from "../constants/authPhases";
import { settingsAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const UpdateNotificationContext = createContext();
@@ -15,7 +18,17 @@ export const useUpdateNotification = () => {
export const UpdateNotificationProvider = ({ children }) => {
const [dismissed, setDismissed] = useState(false);
const { settings, isLoading: settingsLoading } = useSettings();
const { authPhase, isAuthenticated } = useAuth();
// Ensure settings are loaded - but only after auth is fully ready
// This reads cached update info from backend (updated by scheduler)
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
refetchOnWindowFocus: false,
enabled: isAuthReady(authPhase, isAuthenticated()),
});
// Read cached update information from settings (no GitHub API calls)
// The backend scheduler updates this data periodically

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ import {
Copy,
Cpu,
Database,
Download,
Eye,
EyeOff,
HardDrive,
@@ -54,8 +53,6 @@ const HostDetail = () => {
const [historyLimit] = useState(10);
const [notes, setNotes] = useState("");
const [notesMessage, setNotesMessage] = useState({ text: "", type: "" });
const [updateMessage, setUpdateMessage] = useState({ text: "", jobId: "" });
const [reportMessage, setReportMessage] = useState({ text: "", jobId: "" });
const {
data: host,
@@ -190,57 +187,6 @@ const HostDetail = () => {
},
});
// Force agent update mutation
const forceAgentUpdateMutation = useMutation({
mutationFn: () =>
adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data),
onSuccess: (data) => {
queryClient.invalidateQueries(["host", hostId]);
queryClient.invalidateQueries(["hosts"]);
// Show success message with job ID
if (data?.jobId) {
setUpdateMessage({
text: "Update queued successfully",
jobId: data.jobId,
});
// Clear message after 5 seconds
setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
}
},
onError: (error) => {
setUpdateMessage({
text: error.response?.data?.error || "Failed to queue update",
jobId: "",
});
setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
},
});
// Fetch report mutation
const fetchReportMutation = useMutation({
mutationFn: () => adminHostsAPI.fetchReport(hostId).then((res) => res.data),
onSuccess: (data) => {
queryClient.invalidateQueries(["host", hostId]);
queryClient.invalidateQueries(["hosts"]);
// Show success message with job ID
if (data?.jobId) {
setReportMessage({
text: "Report fetch queued successfully",
jobId: data.jobId,
});
// Clear message after 5 seconds
setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
}
},
onError: (error) => {
setReportMessage({
text: error.response?.data?.error || "Failed to fetch report",
jobId: "",
});
setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
},
});
const updateFriendlyNameMutation = useMutation({
mutationFn: (friendlyName) =>
adminHostsAPI
@@ -453,53 +399,20 @@ const HostDetail = () => {
</div>
</div>
<div className="flex items-center gap-2">
<div>
<button
type="button"
onClick={() => fetchReportMutation.mutate()}
disabled={fetchReportMutation.isPending || !wsStatus?.connected}
className="btn-outline flex items-center gap-2 text-sm"
title={
!wsStatus?.connected
? "Agent is not connected"
: "Fetch package data from agent"
}
>
<Download
className={`h-4 w-4 ${
fetchReportMutation.isPending ? "animate-spin" : ""
}`}
/>
Fetch Report
</button>
{reportMessage.text && (
<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400">
{reportMessage.text}
{reportMessage.jobId && (
<span className="ml-1 font-mono text-secondary-500">
(Job #{reportMessage.jobId})
</span>
)}
</p>
)}
</div>
<button
type="button"
onClick={() => setShowCredentialsModal(true)}
className={`btn-outline flex items-center text-sm ${
host?.machine_id ? "justify-center p-2" : "gap-2"
}`}
title="View credentials"
className="btn-outline flex items-center gap-2 text-sm"
>
<Key className="h-4 w-4" />
{!host?.machine_id && <span>Deploy Agent</span>}
Deploy Agent
</button>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center justify-center p-2 text-sm"
title="Refresh dashboard"
title="Refresh host data"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
@@ -790,49 +703,6 @@ const HostDetail = () => {
/>
</button>
</div>
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Force Agent Version Upgrade
</p>
<button
type="button"
onClick={() => forceAgentUpdateMutation.mutate()}
disabled={
forceAgentUpdateMutation.isPending ||
!wsStatus?.connected
}
title={
!wsStatus?.connected
? "Agent is not connected"
: "Force agent to update now"
}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-md hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw
className={`h-3 w-3 ${
forceAgentUpdateMutation.isPending
? "animate-spin"
: ""
}`}
/>
{forceAgentUpdateMutation.isPending
? "Updating..."
: wsStatus?.connected
? "Update Now"
: "Offline"}
</button>
{updateMessage.text && (
<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400">
{updateMessage.text}
{updateMessage.jobId && (
<span className="ml-1 font-mono text-secondary-500">
(Job #{updateMessage.jobId})
</span>
)}
</p>
)}
</div>
</div>
</div>
)}

View File

@@ -470,18 +470,9 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
// Fetch hosts for this group
const { data: hostsData } = useQuery({
queryKey: ["hostGroupHosts", group?.id],
queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
enabled: !!group && group._count?.hosts > 0,
});
const hosts = hostsData || [];
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
@@ -503,30 +494,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800 mb-2">
<p className="text-sm text-warning-800">
<strong>Warning:</strong> This group contains{" "}
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
these hosts before deleting the group.
</p>
{hosts.length > 0 && (
<div className="mt-2">
<p className="text-xs font-medium text-warning-900 mb-1">
Hosts in this group:
</p>
<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2">
{hosts.map((host) => (
<div
key={host.id}
className="text-xs text-warning-900 flex items-center gap-1"
>
<Server className="h-3 w-3" />
{host.friendly_name || host.hostname}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -531,11 +531,12 @@ const Hosts = () => {
"with new data:",
data.host,
);
// Host already has host_group_memberships from backend
// Ensure hostGroupId is set correctly
const updatedHost = {
...data.host,
hostGroupId: data.host.host_groups?.id || null,
};
console.log("Updated host in cache:", updatedHost);
console.log("Updated host with hostGroupId:", updatedHost);
return updatedHost;
}
return host;
@@ -653,15 +654,11 @@ const Hosts = () => {
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
// Group filter - handle multiple groups per host
const memberships = host.host_group_memberships || [];
// Group filter
const matchesGroup =
groupFilter === "all" ||
(groupFilter === "ungrouped" && memberships.length === 0) ||
(groupFilter !== "ungrouped" &&
memberships.some(
(membership) => membership.host_groups?.id === groupFilter,
));
(groupFilter === "ungrouped" && !host.host_groups) ||
(groupFilter !== "ungrouped" && host.host_groups?.id === groupFilter);
// Status filter
const matchesStatus =
@@ -714,30 +711,10 @@ const Hosts = () => {
aValue = a.ip?.toLowerCase() || "zzz_no_ip";
bValue = b.ip?.toLowerCase() || "zzz_no_ip";
break;
case "group": {
// Handle multiple groups per host - use first group alphabetically for sorting
const aGroups = a.host_group_memberships || [];
const bGroups = b.host_group_memberships || [];
if (aGroups.length === 0) {
aValue = "zzz_ungrouped";
} else {
const aGroupNames = aGroups
.map((m) => m.host_groups?.name || "")
.filter((name) => name)
.sort();
aValue = aGroupNames[0] || "zzz_ungrouped";
}
if (bGroups.length === 0) {
bValue = "zzz_ungrouped";
} else {
const bGroupNames = bGroups
.map((m) => m.host_groups?.name || "")
.filter((name) => name)
.sort();
bValue = bGroupNames[0] || "zzz_ungrouped";
}
case "group":
aValue = a.host_groups?.name || "zzz_ungrouped";
bValue = b.host_groups?.name || "zzz_ungrouped";
break;
}
case "os":
aValue = a.os_type?.toLowerCase() || "zzz_unknown";
bValue = b.os_type?.toLowerCase() || "zzz_unknown";
@@ -810,46 +787,27 @@ const Hosts = () => {
const groups = {};
filteredAndSortedHosts.forEach((host) => {
if (groupBy === "group") {
// Handle multiple groups per host
const memberships = host.host_group_memberships || [];
if (memberships.length === 0) {
// Host has no groups, add to "Ungrouped"
if (!groups.Ungrouped) {
groups.Ungrouped = [];
}
groups.Ungrouped.push(host);
} else {
// Host has one or more groups, add to each group
memberships.forEach((membership) => {
const groupName = membership.host_groups?.name || "Unknown";
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(host);
});
}
} else {
// Other grouping types (status, os, etc.)
let groupKey;
switch (groupBy) {
case "status":
groupKey =
(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
(host.effectiveStatus || host.status).slice(1);
break;
case "os":
groupKey = host.os_type || "Unknown";
break;
default:
groupKey = "All Hosts";
}
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(host);
let groupKey;
switch (groupBy) {
case "group":
groupKey = host.host_groups?.name || "Ungrouped";
break;
case "status":
groupKey =
(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
(host.effectiveStatus || host.status).slice(1);
break;
case "os":
groupKey = host.os_type || "Unknown";
break;
default:
groupKey = "All Hosts";
}
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(host);
});
return groups;
@@ -1436,6 +1394,14 @@ const Hosts = () => {
<AlertTriangle className="h-4 w-4" />
Hide Stale
</button>
<button
type="button"
onClick={() => setShowAddModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Host
</button>
</div>
</div>

View File

@@ -17,6 +17,7 @@ import { useEffect, useId, useRef, useState } from "react";
import { FaReddit, FaYoutube } from "react-icons/fa";
import { useNavigate } from "react-router-dom";
import trianglify from "trianglify";
import DiscordIcon from "../components/DiscordIcon";
import { useAuth } from "../contexts/AuthContext";
import { useColorTheme } from "../contexts/ColorThemeContext";
@@ -56,87 +57,27 @@ const Login = () => {
const navigate = useNavigate();
// Generate clean radial gradient background with subtle triangular accents
// Generate Trianglify background based on selected theme
useEffect(() => {
const generateBackground = () => {
if (!canvasRef.current || !themeConfig?.login) return;
if (canvasRef.current && themeConfig?.login) {
// Get current date as seed for daily variation
const today = new Date();
const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
const canvas = canvasRef.current;
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const ctx = canvas.getContext("2d");
// Generate pattern with selected theme configuration
const pattern = trianglify({
width: canvasRef.current.offsetWidth,
height: canvasRef.current.offsetHeight,
cellSize: themeConfig.login.cellSize,
variance: themeConfig.login.variance,
seed: dateSeed,
xColors: themeConfig.login.xColors,
yColors: themeConfig.login.yColors,
});
// Get theme colors - pick first color from each palette
const xColors = themeConfig.login.xColors || [
"#667eea",
"#764ba2",
"#f093fb",
"#4facfe",
];
const yColors = themeConfig.login.yColors || [
"#667eea",
"#764ba2",
"#f093fb",
"#4facfe",
];
// Use date for daily color rotation
const today = new Date();
const seed =
today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
const random = (s) => {
const x = Math.sin(s) * 10000;
return x - Math.floor(x);
};
const color1 = xColors[Math.floor(random(seed) * xColors.length)];
const color2 = yColors[Math.floor(random(seed + 1000) * yColors.length)];
// Create clean radial gradient from center to bottom-right corner
const gradient = ctx.createRadialGradient(
canvas.width * 0.3, // Center slightly left
canvas.height * 0.3, // Center slightly up
0,
canvas.width * 0.5, // Expand to cover screen
canvas.height * 0.5,
Math.max(canvas.width, canvas.height) * 1.2,
);
// Subtle gradient with darker corners
gradient.addColorStop(0, color1);
gradient.addColorStop(0.6, color2);
gradient.addColorStop(1, "#0a0a0a"); // Very dark edges
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add subtle triangular shapes as accents across entire background
const cellSize = 180;
const cols = Math.ceil(canvas.width / cellSize) + 1;
const rows = Math.ceil(canvas.height / cellSize) + 1;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const idx = y * cols + x;
// Draw more triangles (less sparse)
if (random(seed + idx + 5000) > 0.4) {
const baseX =
x * cellSize + random(seed + idx * 3) * cellSize * 0.8;
const baseY =
y * cellSize + random(seed + idx * 3 + 100) * cellSize * 0.8;
const size = 50 + random(seed + idx * 4) * 100;
ctx.beginPath();
ctx.moveTo(baseX, baseY);
ctx.lineTo(baseX + size, baseY);
ctx.lineTo(baseX + size / 2, baseY - size * 0.866);
ctx.closePath();
// More visible white with slightly higher opacity
ctx.fillStyle = `rgba(255, 255, 255, ${0.05 + random(seed + idx * 5) * 0.08})`;
ctx.fill();
}
}
// Render to canvas
pattern.toCanvas(canvasRef.current);
}
};
@@ -149,7 +90,7 @@ const Login = () => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [themeConfig]);
}, [themeConfig]); // Regenerate when theme changes
// Check if signup is enabled
useEffect(() => {
@@ -248,8 +189,7 @@ const Login = () => {
} catch (error) {
console.error("Failed to fetch GitHub data:", error);
// Set fallback data if nothing cached
const cachedRelease = localStorage.getItem("githubLatestRelease");
if (!cachedRelease) {
if (!latestRelease) {
setLatestRelease({
version: "v1.3.0",
name: "Latest Release",
@@ -261,7 +201,7 @@ const Login = () => {
};
fetchGitHubData();
}, []); // Run once on mount
}, [latestRelease]);
const handleSubmit = async (e) => {
e.preventDefault();
@@ -408,12 +348,7 @@ const Login = () => {
setTfaData({
...tfaData,
[name]:
type === "checkbox"
? checked
: value
.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.slice(0, 6),
type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6),
});
// Clear error when user starts typing
if (error) {
@@ -878,8 +813,7 @@ const Login = () => {
Two-Factor Authentication
</h3>
<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400">
Enter the code from your authenticator app, or use a backup
code
Enter the 6-digit code from your authenticator app
</p>
</div>
@@ -898,15 +832,11 @@ const Login = () => {
required
value={tfaData.token}
onChange={handleTfaInputChange}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest uppercase"
placeholder="Enter code"
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
placeholder="000000"
maxLength="6"
pattern="[A-Z0-9]{6}"
/>
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Enter a 6-digit TOTP code or a 6-character backup code
</p>
</div>
<div className="flex items-center">
@@ -966,6 +896,12 @@ const Login = () => {
Back to Login
</button>
</div>
<div className="text-center">
<p className="text-sm text-secondary-600 dark:text-secondary-400">
Don't have access to your authenticator? Use a backup code.
</p>
</div>
</form>
)}
</div>

View File

@@ -557,18 +557,9 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
// Fetch hosts for this group
const { data: hostsData } = useQuery({
queryKey: ["hostGroupHosts", group?.id],
queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
enabled: !!group && group._count?.hosts > 0,
});
const hosts = hostsData || [];
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
@@ -590,30 +581,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800 mb-2">
<p className="text-sm text-warning-800">
<strong>Warning:</strong> This group contains{" "}
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
these hosts before deleting the group.
</p>
{hosts.length > 0 && (
<div className="mt-2">
<p className="text-xs font-medium text-warning-900 mb-1">
Hosts in this group:
</p>
<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2">
{hosts.map((host) => (
<div
key={host.id}
className="text-xs text-warning-900 flex items-center gap-1"
>
<Server className="h-3 w-3" />
{host.friendly_name || host.hostname}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -539,7 +539,7 @@ const Packages = () => {
<Package className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Packages
Total Packages
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{totalPackagesCount}
@@ -553,7 +553,7 @@ const Packages = () => {
<Package className="h-5 w-5 text-blue-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Installations
Total Installations
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{totalInstallationsCount}
@@ -562,72 +562,47 @@ const Packages = () => {
</div>
</div>
<button
type="button"
onClick={() => {
setUpdateStatusFilter("needs-updates");
setCategoryFilter("all");
setHostFilter("all");
setSearchTerm("");
}}
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
title="Click to filter packages that need updates"
>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Package className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Outdated Packages
Total Outdated Packages
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{outdatedPackagesCount}
</p>
</div>
</div>
</button>
</div>
<button
type="button"
onClick={() => {
setUpdateStatusFilter("security-updates");
setCategoryFilter("all");
setHostFilter("all");
setSearchTerm("");
}}
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
title="Click to filter packages with security updates"
>
<div className="flex items-center">
<Shield className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Security Packages
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{securityUpdatesCount}
</p>
</div>
</div>
</button>
<button
type="button"
onClick={() => navigate("/hosts?filter=needsUpdates")}
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
title="Click to view hosts that need updates"
>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Server className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Outdated Hosts
Hosts Pending Updates
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniquePackageHostsCount}
</p>
</div>
</div>
</button>
</div>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Shield className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Security Updates Across All Hosts
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{securityUpdatesCount}
</p>
</div>
</div>
</div>
</div>
{/* Packages List */}

View File

@@ -25,7 +25,6 @@ import {
import { useEffect, useId, useState } from "react";
import { useAuth } from "../contexts/AuthContext";
import { THEME_PRESETS, useColorTheme } from "../contexts/ColorThemeContext";
import { useTheme } from "../contexts/ThemeContext";
import { isCorsError, tfaAPI } from "../utils/api";
@@ -39,7 +38,6 @@ const Profile = () => {
const confirmPasswordId = useId();
const { user, updateProfile, changePassword } = useAuth();
const { toggleTheme, isDark } = useTheme();
const { colorTheme, setColorTheme } = useColorTheme();
const [activeTab, setActiveTab] = useState("profile");
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" });
@@ -80,10 +78,8 @@ const Profile = () => {
setIsLoading(true);
setMessage({ type: "", text: "" });
console.log("Submitting profile data:", profileData);
try {
const result = await updateProfile(profileData);
console.log("Profile update result:", result);
if (result.success) {
setMessage({ type: "success", text: "Profile updated successfully!" });
} else {
@@ -415,68 +411,6 @@ const Profile = () => {
</button>
</div>
</div>
{/* Color Theme Settings */}
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
Color Theme
</h4>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mb-4">
Choose your preferred color scheme for the application
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
const isSelected = colorTheme === themeKey;
const gradientColors = theme.login.xColors;
return (
<button
key={themeKey}
type="button"
onClick={() => setColorTheme(themeKey)}
className={`relative p-4 rounded-lg border-2 transition-all ${
isSelected
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
} cursor-pointer`}
>
{/* Theme Preview */}
<div
className="h-20 rounded-md mb-3 overflow-hidden"
style={{
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
}}
/>
{/* Theme Name */}
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
{theme.name}
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
aria-label="Selected theme"
>
<title>Selected</title>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</button>
);
})}
</div>
</div>
</div>
<div className="flex justify-end">
@@ -630,7 +564,6 @@ const Profile = () => {
// TFA Tab Component
const TfaTab = () => {
const verificationTokenId = useId();
const disablePasswordId = useId();
const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
const [verificationToken, setVerificationToken] = useState("");
const [password, setPassword] = useState("");

View File

@@ -1,483 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
CheckCircle,
Container,
Globe,
Network,
RefreshCw,
Server,
Tag,
XCircle,
} from "lucide-react";
import { Link, useParams } from "react-router-dom";
import api, { formatRelativeTime } from "../../utils/api";
const NetworkDetail = () => {
const { id } = useParams();
const { data, isLoading, error } = useQuery({
queryKey: ["docker", "network", id],
queryFn: async () => {
const response = await api.get(`/docker/networks/${id}`);
return response.data;
},
refetchInterval: 30000,
});
const network = data?.network;
const host = data?.host;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
</div>
);
}
if (error || !network) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-red-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Network not found
</h3>
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
The network you're looking for doesn't exist or has been
removed.
</p>
</div>
</div>
</div>
<Link
to="/docker"
className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Docker
</Link>
</div>
);
}
const BooleanBadge = ({ value, trueLabel = "Yes", falseLabel = "No" }) => {
return value ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<CheckCircle className="h-3 w-3 mr-1" />
{trueLabel}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
<XCircle className="h-3 w-3 mr-1" />
{falseLabel}
</span>
);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<Link
to="/docker"
className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Docker
</Link>
<div className="flex items-center">
<Network className="h-8 w-8 text-secondary-400 mr-3" />
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
{network.name}
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Network ID: {network.network_id.substring(0, 12)}
</p>
</div>
</div>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Network className="h-5 w-5 text-blue-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Driver
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{network.driver}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Globe className="h-5 w-5 text-purple-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Scope
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{network.scope}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Container className="h-5 w-5 text-green-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Containers
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{network.container_count || 0}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Last Checked
</p>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{formatRelativeTime(network.last_checked)}
</p>
</div>
</div>
</div>
</div>
{/* Network Information Card */}
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
Network Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Network ID
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
{network.network_id}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Name
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{network.name}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Driver
</dt>
<dd className="mt-1">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{network.driver}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Scope
</dt>
<dd className="mt-1">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
{network.scope}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Containers Attached
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{network.container_count || 0}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
IPv6 Enabled
</dt>
<dd className="mt-1">
<BooleanBadge value={network.ipv6_enabled} />
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Internal
</dt>
<dd className="mt-1">
<BooleanBadge value={network.internal} />
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Attachable
</dt>
<dd className="mt-1">
<BooleanBadge value={network.attachable} />
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Ingress
</dt>
<dd className="mt-1">
<BooleanBadge value={network.ingress} />
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Config Only
</dt>
<dd className="mt-1">
<BooleanBadge value={network.config_only} />
</dd>
</div>
{network.created_at && (
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Created
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatRelativeTime(network.created_at)}
</dd>
</div>
)}
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Last Checked
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatRelativeTime(network.last_checked)}
</dd>
</div>
</dl>
</div>
</div>
{/* IPAM Configuration */}
{network.ipam && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
IPAM Configuration
</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
IP Address Management settings
</p>
</div>
<div className="px-6 py-5">
{network.ipam.driver && (
<div className="mb-4">
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-1">
Driver
</dt>
<dd>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{network.ipam.driver}
</span>
</dd>
</div>
)}
{network.ipam.config && network.ipam.config.length > 0 && (
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-3">
Subnet Configuration
</dt>
<div className="space-y-4">
{network.ipam.config.map((config, index) => (
<div
key={config.subnet || `config-${index}`}
className="bg-secondary-50 dark:bg-secondary-900/50 rounded-lg p-4"
>
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
{config.subnet && (
<div>
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
Subnet
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
{config.subnet}
</dd>
</div>
)}
{config.gateway && (
<div>
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
Gateway
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
{config.gateway}
</dd>
</div>
)}
{config.ip_range && (
<div>
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
IP Range
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
{config.ip_range}
</dd>
</div>
)}
{config.aux_addresses &&
Object.keys(config.aux_addresses).length > 0 && (
<div className="sm:col-span-2">
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400 mb-2">
Auxiliary Addresses
</dt>
<dd className="space-y-1">
{Object.entries(config.aux_addresses).map(
([key, value]) => (
<div
key={key}
className="flex items-center text-sm"
>
<span className="text-secondary-500 dark:text-secondary-400 min-w-[120px]">
{key}:
</span>
<span className="text-secondary-900 dark:text-white font-mono">
{value}
</span>
</div>
),
)}
</dd>
</div>
)}
</dl>
</div>
))}
</div>
</div>
)}
{network.ipam.options &&
Object.keys(network.ipam.options).length > 0 && (
<div className="mt-4">
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
IPAM Options
</dt>
<dd className="space-y-1">
{Object.entries(network.ipam.options).map(
([key, value]) => (
<div
key={key}
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
{key}
</span>
<span className="text-sm text-secondary-900 dark:text-white break-all">
{value}
</span>
</div>
),
)}
</dd>
</div>
)}
</div>
</div>
)}
{/* Host Information */}
{host && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
<Server className="h-5 w-5 mr-2" />
Host Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Hostname
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
<Link
to={`/hosts/${host.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
{host.hostname}
</Link>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Operating System
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{host.os_name} {host.os_version}
</dd>
</div>
</dl>
</div>
</div>
)}
{/* Labels */}
{network.labels && Object.keys(network.labels).length > 0 && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
<Tag className="h-5 w-5 mr-2" />
Labels
</h3>
</div>
<div className="px-6 py-5">
<div className="space-y-2">
{Object.entries(network.labels).map(([key, value]) => (
<div
key={key}
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
{key}
</span>
<span className="text-sm text-secondary-900 dark:text-white break-all">
{value}
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default NetworkDetail;

View File

@@ -1,359 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
Database,
HardDrive,
RefreshCw,
Server,
Tag,
} from "lucide-react";
import { Link, useParams } from "react-router-dom";
import api, { formatRelativeTime } from "../../utils/api";
const VolumeDetail = () => {
const { id } = useParams();
const { data, isLoading, error } = useQuery({
queryKey: ["docker", "volume", id],
queryFn: async () => {
const response = await api.get(`/docker/volumes/${id}`);
return response.data;
},
refetchInterval: 30000,
});
const volume = data?.volume;
const host = data?.host;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
</div>
);
}
if (error || !volume) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-red-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Volume not found
</h3>
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
The volume you're looking for doesn't exist or has been removed.
</p>
</div>
</div>
</div>
<Link
to="/docker"
className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Docker
</Link>
</div>
);
}
const formatBytes = (bytes) => {
if (bytes === null || bytes === undefined) return "N/A";
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Bytes";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<Link
to="/docker"
className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Docker
</Link>
<div className="flex items-center">
<HardDrive className="h-8 w-8 text-secondary-400 mr-3" />
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
{volume.name}
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Volume ID: {volume.volume_id}
</p>
</div>
</div>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<HardDrive className="h-5 w-5 text-blue-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Driver
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{volume.driver}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Database className="h-5 w-5 text-purple-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Size</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{formatBytes(volume.size_bytes)}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Server className="h-5 w-5 text-green-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Containers
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{volume.ref_count || 0}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Last Checked
</p>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{formatRelativeTime(volume.last_checked)}
</p>
</div>
</div>
</div>
</div>
{/* Volume Information Card */}
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
Volume Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Volume ID
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
{volume.volume_id}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Name
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{volume.name}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Driver
</dt>
<dd className="mt-1">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{volume.driver}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Scope
</dt>
<dd className="mt-1">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
{volume.scope}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Size
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatBytes(volume.size_bytes)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Containers Using
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{volume.ref_count || 0}
</dd>
</div>
{volume.mountpoint && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Mount Point
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
{volume.mountpoint}
</dd>
</div>
)}
{volume.renderer && (
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Renderer
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{volume.renderer}
</dd>
</div>
)}
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Created
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatRelativeTime(volume.created_at)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Last Checked
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatRelativeTime(volume.last_checked)}
</dd>
</div>
</dl>
</div>
</div>
{/* Host Information */}
{host && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
<Server className="h-5 w-5 mr-2" />
Host Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Hostname
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
<Link
to={`/hosts/${host.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
{host.hostname}
</Link>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Operating System
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{host.os_name} {host.os_version}
</dd>
</div>
</dl>
</div>
</div>
)}
{/* Labels */}
{volume.labels && Object.keys(volume.labels).length > 0 && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
<Tag className="h-5 w-5 mr-2" />
Labels
</h3>
</div>
<div className="px-6 py-5">
<div className="space-y-2">
{Object.entries(volume.labels).map(([key, value]) => (
<div
key={key}
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
{key}
</span>
<span className="text-sm text-secondary-900 dark:text-white break-all">
{value}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Options */}
{volume.options && Object.keys(volume.options).length > 0 && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
Volume Options
</h3>
</div>
<div className="px-6 py-5">
<div className="space-y-2">
{Object.entries(volume.options).map(([key, value]) => (
<div
key={key}
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
{key}
</span>
<span className="text-sm text-secondary-900 dark:text-white break-all">
{value}
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default VolumeDetail;

View File

@@ -746,126 +746,239 @@ const Integrations = () => {
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Docker Inventory Collection
Docker Container Monitoring
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
Docker monitoring is now built into the PatchMon Go agent
Monitor Docker containers and images for available updates
</p>
</div>
</div>
{/* Info Message */}
{/* Installation Instructions */}
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
<div>
<h4 className="text-md font-semibold text-primary-900 dark:text-primary-200 mb-2">
Automatic Docker Discovery
</h4>
<p className="text-sm text-primary-800 dark:text-primary-300 mb-3">
The PatchMon Go agent automatically discovers Docker
when it's available on your host and collects
comprehensive inventory information:
</p>
<ul className="list-disc list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300 ml-2">
<li>
<strong>Containers</strong> - Running and stopped
containers with status, images, ports, and labels
</li>
<li>
<strong>Images</strong> - All Docker images with
repository, tags, sizes, and sources
</li>
<li>
<strong>Volumes</strong> - Named and anonymous volumes
with drivers, mountpoints, and usage
</li>
<li>
<strong>Networks</strong> - Docker networks with
drivers, IPAM configuration, and connected containers
</li>
<li>
<strong>Real-time Updates</strong> - Container status
changes are pushed instantly via WebSocket
</li>
</ul>
</div>
</div>
</div>
{/* How It Works */}
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-4">
How It Works
</h4>
<ol className="list-decimal list-inside space-y-3 text-sm text-secondary-700 dark:text-secondary-300">
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-4">
Agent Installation
</h3>
<ol className="list-decimal list-inside space-y-3 text-sm text-primary-800 dark:text-primary-300">
<li>
Install the PatchMon Go agent on your host (see the Hosts
page for installation instructions)
Make sure you have the PatchMon credentials file set up on
your host (
<code className="bg-primary-100 dark:bg-primary-900/40 px-1 py-0.5 rounded text-xs">
/etc/patchmon/credentials
</code>
)
</li>
<li>
The agent automatically detects if Docker is installed and
running on the host
SSH into your Docker host where you want to monitor
containers
</li>
<li>Run the installation command below</li>
<li>
During each collection cycle, the agent gathers Docker
inventory data and sends it to the PatchMon server
</li>
<li>
View your complete Docker inventory (containers, images,
volumes, networks) in the{" "}
<a
href="/docker"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline"
>
Docker page
</a>
</li>
<li>
Container status changes are pushed to the server in
real-time via WebSocket connection
The agent will automatically collect Docker container and
image information every 5 minutes
</li>
<li>View your Docker inventory in the Docker page</li>
</ol>
</div>
{/* No Configuration Required */}
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-green-800 dark:text-green-200">
<p className="font-semibold mb-1">
No Additional Configuration Required
</p>
<p>
Once the Go agent is installed and Docker is running on
your host, Docker inventory collection happens
automatically. No separate Docker agent or cron jobs
needed.
{/* Installation Command */}
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
Quick Installation (One-Line Command)
</h4>
<div className="space-y-3">
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Download and install the Docker agent:
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`}
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"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`,
"docker-install",
)
}
className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap"
>
{copy_success["docker-install"] ? (
<>
<CheckCircle className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</button>
</div>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
💡 This will download the agent, make it executable, and
set up a cron job to run every 5 minutes
</p>
</div>
</div>
</div>
{/* Requirements */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
{/* Manual Installation Steps */}
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
Manual Installation Steps
</h4>
<div className="space-y-4">
<div>
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
<strong>Step 1:</strong> Download the agent
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`}
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"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`,
"docker-download",
)
}
className="btn-primary p-2"
>
{copy_success["docker-download"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
<strong>Step 2:</strong> Make it executable
</p>
<div className="flex items-center gap-2">
<input
type="text"
value="chmod +x /usr/local/bin/patchmon-docker-agent.sh"
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"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
"chmod +x /usr/local/bin/patchmon-docker-agent.sh",
"docker-chmod",
)
}
className="btn-primary p-2"
>
{copy_success["docker-chmod"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
<strong>Step 3:</strong> Test the agent
</p>
<div className="flex items-center gap-2">
<input
type="text"
value="/usr/local/bin/patchmon-docker-agent.sh collect"
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"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
"/usr/local/bin/patchmon-docker-agent.sh collect",
"docker-test",
)
}
className="btn-primary p-2"
>
{copy_success["docker-test"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
<strong>Step 4:</strong> Set up automatic collection
(every 5 minutes)
</p>
<div className="flex items-center gap-2">
<input
type="text"
value='echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -'
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"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
'echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -',
"docker-cron",
)
}
className="btn-primary p-2"
>
{copy_success["docker-cron"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
</div>
{/* Prerequisites */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-semibold mb-2">Requirements:</p>
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold mb-2">Prerequisites:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>PatchMon Go agent must be installed and running</li>
<li>Docker daemon must be installed and running</li>
<li>
Agent must have access to the Docker socket (
<code className="bg-blue-100 dark:bg-blue-900/40 px-1 py-0.5 rounded text-xs">
/var/run/docker.sock
Docker must be installed and running on the host
</li>
<li>
PatchMon credentials file must exist at{" "}
<code className="bg-yellow-100 dark:bg-yellow-900/40 px-1 py-0.5 rounded text-xs">
/etc/patchmon/credentials
</code>
)
</li>
<li>
Typically requires running the agent as root or with
Docker group permissions
The host must have network access to your PatchMon
server
</li>
<li>The agent must run as root (or with sudo)</li>
</ul>
</div>
</div>

View File

@@ -215,8 +215,8 @@ const SettingsHostGroups = () => {
title={`View hosts in ${group.name}`}
>
<Server className="h-4 w-4 mr-2" />
{group._count?.hosts || 0} host
{group._count?.hosts !== 1 ? "s" : ""}
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
@@ -539,18 +539,9 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
// Fetch hosts for this group
const { data: hostsData } = useQuery({
queryKey: ["hostGroupHosts", group?.id],
queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
enabled: !!group && group._count?.hosts > 0,
});
const hosts = hostsData || [];
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
@@ -570,32 +561,14 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
Are you sure you want to delete the host group{" "}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count?.hosts > 0 && (
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800 mb-2">
<strong>Note:</strong> This group contains {group._count?.hosts}{" "}
<p className="text-sm text-blue-800">
<strong>Note:</strong> This group contains {group._count.hosts}{" "}
host
{group._count?.hosts !== 1 ? "s" : ""}. These hosts will be
moved to "No group" after deletion.
{group._count.hosts !== 1 ? "s" : ""}. These hosts will be moved
to "No group" after deletion.
</p>
{hosts.length > 0 && (
<div className="mt-2">
<p className="text-xs font-medium text-blue-900 mb-1">
Hosts in this group:
</p>
<div className="max-h-32 overflow-y-auto bg-blue-100 rounded p-2">
{hosts.map((host) => (
<div
key={host.id}
className="text-xs text-blue-900 flex items-center gap-1"
>
<Server className="h-3 w-3" />
{host.friendly_name || host.hostname}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BarChart3,
BookOpen,
CheckCircle,
Eye,
EyeOff,
@@ -179,19 +178,6 @@ const SettingsMetrics = () => {
</div>
</div>
</div>
{/* More Information Button */}
<div className="mt-4 pt-4 border-t border-blue-200 dark:border-blue-700">
<a
href="https://docs.patchmon.net/books/patchmon-application-documentation/page/metrics-collection-information"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
>
<BookOpen className="h-4 w-4 mr-2" />
More Information
</a>
</div>
</div>
{/* Metrics Toggle */}

View File

@@ -19,30 +19,6 @@ api.interceptors.request.use(
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add device ID for TFA remember-me functionality
// This uniquely identifies the browser profile (normal vs incognito)
let deviceId = localStorage.getItem("device_id");
if (!deviceId) {
// Generate a unique device ID and store it
// Use crypto.randomUUID() if available, otherwise generate a UUID v4 manually
if (typeof crypto !== "undefined" && crypto.randomUUID) {
deviceId = crypto.randomUUID();
} else {
// Fallback: Generate UUID v4 manually
deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
(c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}
localStorage.setItem("device_id", deviceId);
}
config.headers["X-Device-ID"] = deviceId;
return config;
},
(error) => {
@@ -119,8 +95,6 @@ export const adminHostsAPI = {
api.put("/hosts/bulk/groups", { hostIds, groupIds }),
toggleAutoUpdate: (hostId, autoUpdate) =>
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
forceAgentUpdate: (hostId) => api.post(`/hosts/${hostId}/force-agent-update`),
fetchReport: (hostId) => api.post(`/hosts/${hostId}/fetch-report`),
updateFriendlyName: (hostId, friendlyName) =>
api.patch(`/hosts/${hostId}/friendly-name`, {
friendly_name: friendlyName,
@@ -169,12 +143,6 @@ export const settingsAPI = {
getServerUrl: () => api.get("/settings/server-url"),
};
// User Preferences API
export const userPreferencesAPI = {
get: () => api.get("/user/preferences"),
update: (preferences) => api.patch("/user/preferences", preferences),
};
// Agent File Management API
export const agentFileAPI = {
getInfo: () => api.get("/hosts/agent/info"),

View File

@@ -1,171 +0,0 @@
/**
* Docker-related utility functions for the frontend
*/
/**
* Generate a registry link for a Docker image based on its repository and source
* @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo")
* @param {string} source - The detected source (github, gitlab, docker-hub, etc.)
* @returns {string|null} - The URL to the registry page, or null if unknown
*/
export function generateRegistryLink(repository, source) {
if (!repository) {
return null;
}
// Parse the domain and path from the repository
const parts = repository.split("/");
let domain = "";
let path = "";
// Check if repository has a domain (contains a dot)
if (parts[0].includes(".") || parts[0].includes(":")) {
domain = parts[0];
path = parts.slice(1).join("/");
} else {
// No domain means Docker Hub
domain = "docker.io";
path = repository;
}
switch (source) {
case "docker-hub":
case "docker.io": {
// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images
// Official images are those without a namespace (e.g., "postgres" not "user/postgres")
// or explicitly prefixed with "library/"
if (path.startsWith("library/")) {
const cleanPath = path.replace("library/", "");
return `https://hub.docker.com/_/${cleanPath}`;
}
// Check if it's an official image (single part, no slash after removing library/)
if (!path.includes("/")) {
return `https://hub.docker.com/_/${path}`;
}
// Regular user/org image
return `https://hub.docker.com/r/${path}`;
}
case "github":
case "ghcr.io": {
// GitHub Container Registry
// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package}
// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package}
if (domain === "ghcr.io" && path) {
const pathParts = path.split("/");
if (pathParts.length === 2) {
// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package
// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package}
// Actually, for 2 parts it's owner/package, and repo is same as owner typically
const owner = pathParts[0];
const packageName = pathParts[1];
return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`;
} else if (pathParts.length >= 3) {
// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package
const owner = pathParts[0];
const repo = pathParts[1];
const packageName = pathParts.slice(2).join("/");
return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`;
}
}
// Legacy GitHub Packages
if (domain === "docker.pkg.github.com" && path) {
const pathParts = path.split("/");
if (pathParts.length >= 1) {
return `https://github.com/${pathParts[0]}/packages`;
}
}
return null;
}
case "gitlab":
case "registry.gitlab.com": {
// GitLab Container Registry
if (path) {
return `https://gitlab.com/${path}/container_registry`;
}
return null;
}
case "google":
case "gcr.io": {
// Google Container Registry
if (domain.includes("gcr.io") || domain.includes("pkg.dev")) {
return `https://console.cloud.google.com/gcr/images/${path}`;
}
return null;
}
case "quay":
case "quay.io": {
// Quay.io
if (path) {
return `https://quay.io/repository/${path}`;
}
return null;
}
case "redhat":
case "registry.access.redhat.com": {
// Red Hat
if (path) {
return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`;
}
return null;
}
case "azure":
case "azurecr.io": {
// Azure Container Registry
if (domain.includes("azurecr.io")) {
const registryName = domain.split(".")[0];
return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`;
}
return null;
}
case "aws":
case "amazonaws.com": {
// AWS ECR
if (domain.includes("amazonaws.com")) {
const domainParts = domain.split(".");
const region = domainParts[3]; // Extract region
return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`;
}
return null;
}
case "private":
// For private registries, try to construct a basic URL
if (domain) {
return `https://${domain}`;
}
return null;
default:
return null;
}
}
/**
* Get a user-friendly display name for a registry source
* @param {string} source - The source identifier
* @returns {string} - Human-readable source name
*/
export function getSourceDisplayName(source) {
const sourceNames = {
"docker-hub": "Docker Hub",
github: "GitHub",
gitlab: "GitLab",
google: "Google",
quay: "Quay.io",
redhat: "Red Hat",
azure: "Azure",
aws: "AWS ECR",
private: "Private Registry",
local: "Local",
unknown: "Unknown",
};
return sourceNames[source] || source;
}

706
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon",
"version": "1.3.2",
"version": "1.3.1",
"description": "Linux Patch Monitoring System",
"license": "AGPL-3.0",
"private": true,

185
setup.sh
View File

@@ -34,7 +34,7 @@ BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Global variables
SCRIPT_VERSION="self-hosting-install.sh v1.3.2-selfhost-2025-10-31-1"
SCRIPT_VERSION="self-hosting-install.sh v1.3.0-selfhost-2025-10-19-1"
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
FQDN=""
CUSTOM_FQDN=""
@@ -1797,12 +1797,7 @@ create_agent_version() {
cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/"
print_status "Agent version management removed - using file-based approach"
fi
# Make agent binaries executable
if [ -d "$APP_DIR/agents" ]; then
chmod +x "$APP_DIR/agents/patchmon-agent-linux-"* 2>/dev/null || true
print_status "Agent binaries made executable"
# Ensure we close the conditional and the function properly
fi
return 0
@@ -2197,66 +2192,34 @@ select_installation_to_update() {
version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
fi
# Get service status - search for service files that reference this installation
local service_name=""
# Get service status - try multiple naming conventions
# Convention 1: Just the install name (e.g., patchmon.internal)
local service_name="$install"
# Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal)
local alt_service_name1="patchmon.$install"
# Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal)
local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')"
local status="unknown"
# Search systemd directory for service files that reference this installation
for service_file in /etc/systemd/system/*.service; do
if [ -f "$service_file" ]; then
# Check if this service file references our installation directory
if grep -q "/opt/$install" "$service_file"; then
service_name=$(basename "$service_file" .service)
# Check service status
if systemctl is-active --quiet "$service_name" 2>/dev/null; then
status="running"
break
elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
status="stopped"
break
fi
fi
fi
done
# If not found by searching, try common naming conventions
if [ -z "$service_name" ] || [ "$status" == "unknown" ]; then
# Convention 1: Just the install name (e.g., patchmon.internal)
local try_service="$install"
# Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal)
local alt_service_name1="patchmon.$install"
# Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal)
local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')"
# Try convention 1 first (most common)
if systemctl is-active --quiet "$try_service" 2>/dev/null; then
status="running"
service_name="$try_service"
elif systemctl is-enabled --quiet "$try_service" 2>/dev/null; then
status="stopped"
service_name="$try_service"
# Try convention 2
elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then
status="running"
service_name="$alt_service_name1"
elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then
status="stopped"
service_name="$alt_service_name1"
# Try convention 3
elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then
status="running"
service_name="$alt_service_name2"
elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then
status="stopped"
service_name="$alt_service_name2"
fi
fi
# Fallback: if still no service found, use default naming convention
if [ -z "$service_name" ]; then
service_name="$install"
status="not_found"
# Try convention 1 first (most common)
if systemctl is-active --quiet "$service_name" 2>/dev/null; then
status="running"
elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
status="stopped"
# Try convention 2
elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then
status="running"
service_name="$alt_service_name1"
elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then
status="stopped"
service_name="$alt_service_name1"
# Try convention 3
elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then
status="running"
service_name="$alt_service_name2"
elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then
status="stopped"
service_name="$alt_service_name2"
fi
printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
@@ -2740,13 +2703,6 @@ update_env_file() {
: ${TFA_MAX_REMEMBER_SESSIONS:=5}
: ${TFA_SUSPICIOUS_ACTIVITY_THRESHOLD:=3}
# Prisma Connection Pool
: ${DB_CONNECTION_LIMIT:=30}
: ${DB_POOL_TIMEOUT:=20}
: ${DB_CONNECT_TIMEOUT:=10}
: ${DB_IDLE_TIMEOUT:=300}
: ${DB_MAX_LIFETIME:=1800}
# Track which variables were added
local added_vars=()
@@ -2808,21 +2764,6 @@ update_env_file() {
if ! grep -q "^TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=" "$env_file"; then
added_vars+=("TFA_SUSPICIOUS_ACTIVITY_THRESHOLD")
fi
if ! grep -q "^DB_CONNECTION_LIMIT=" "$env_file"; then
added_vars+=("DB_CONNECTION_LIMIT")
fi
if ! grep -q "^DB_POOL_TIMEOUT=" "$env_file"; then
added_vars+=("DB_POOL_TIMEOUT")
fi
if ! grep -q "^DB_CONNECT_TIMEOUT=" "$env_file"; then
added_vars+=("DB_CONNECT_TIMEOUT")
fi
if ! grep -q "^DB_IDLE_TIMEOUT=" "$env_file"; then
added_vars+=("DB_IDLE_TIMEOUT")
fi
if ! grep -q "^DB_MAX_LIFETIME=" "$env_file"; then
added_vars+=("DB_MAX_LIFETIME")
fi
# If there are missing variables, add them
if [ ${#added_vars[@]} -gt 0 ]; then
@@ -2908,25 +2849,6 @@ EOF
echo "TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=$TFA_SUSPICIOUS_ACTIVITY_THRESHOLD" >> "$env_file"
fi
# Add Prisma connection pool config if missing
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_CONNECTION_LIMIT"; then
echo "" >> "$env_file"
echo "# Database Connection Pool Configuration (Prisma)" >> "$env_file"
echo "DB_CONNECTION_LIMIT=$DB_CONNECTION_LIMIT" >> "$env_file"
fi
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_POOL_TIMEOUT"; then
echo "DB_POOL_TIMEOUT=$DB_POOL_TIMEOUT" >> "$env_file"
fi
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_CONNECT_TIMEOUT"; then
echo "DB_CONNECT_TIMEOUT=$DB_CONNECT_TIMEOUT" >> "$env_file"
fi
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_IDLE_TIMEOUT"; then
echo "DB_IDLE_TIMEOUT=$DB_IDLE_TIMEOUT" >> "$env_file"
fi
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_MAX_LIFETIME"; then
echo "DB_MAX_LIFETIME=$DB_MAX_LIFETIME" >> "$env_file"
fi
print_status ".env file updated with ${#added_vars[@]} new variable(s)"
print_info "Added variables: ${added_vars[*]}"
else
@@ -2996,37 +2918,11 @@ update_installation() {
print_info "Installation directory: $instance_dir"
print_info "Service name: $service_name"
# Verify it's a git repository, if not, initialize it
# Verify it's a git repository
if [ ! -d "$instance_dir/.git" ]; then
print_warning "Installation directory is not a git repository"
print_info "Attempting to re-initialize as git repository..."
cd "$instance_dir" || exit 1
# Initialize git repository
git init
git remote add origin https://github.com/PatchMon/PatchMon.git
# Fetch all branches
git fetch origin
# Try to determine current version from package.json or default to main
local current_branch="main"
if [ -f "$instance_dir/backend/package.json" ]; then
local pkg_version=$(grep '"version"' "$instance_dir/backend/package.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')
if [ -n "$pkg_version" ]; then
# Check if there's a release branch for this version
if git ls-remote --heads origin | grep -q "release/$(echo $pkg_version | sed 's/\./-/g')"; then
current_branch="release/$(echo $pkg_version | sed 's/\./-/g')"
fi
fi
fi
# Reset to the determined branch
git reset --hard "origin/$current_branch"
git checkout -B "$current_branch" "origin/$current_branch"
print_success "Repository initialized successfully"
print_error "Installation directory is not a git repository"
print_error "Cannot perform git-based update"
exit 1
fi
# Add git safe.directory to avoid ownership issues when running as root
@@ -3035,8 +2931,6 @@ update_installation() {
# Load existing .env to get database credentials
if [ -f "$instance_dir/backend/.env" ]; then
# Unset color variables before sourcing to prevent ANSI escape sequences from leaking into .env
unset RED GREEN YELLOW BLUE NC
source "$instance_dir/backend/.env"
print_status "Loaded existing configuration"
@@ -3104,16 +2998,11 @@ update_installation() {
# Clean up any untracked files that might conflict with incoming changes
print_info "Cleaning up untracked files to prevent merge conflicts..."
git clean -fd 2>/dev/null || true
git clean -fd
# Reset any local changes to ensure clean state
# Check if HEAD exists before trying to reset
print_info "Resetting local changes to ensure clean state..."
if git rev-parse --verify HEAD >/dev/null 2>&1; then
git reset --hard HEAD
else
print_warning "HEAD not found, skipping reset (fresh repository or detached state)"
fi
git reset --hard HEAD
# Fetch latest changes
git fetch origin
@@ -3137,12 +3026,6 @@ update_installation() {
print_info "Building frontend..."
npm run build
# Make agent binaries executable
if [ -d "$instance_dir/agents" ]; then
chmod +x "$instance_dir/agents/patchmon-agent-linux-"* 2>/dev/null || true
print_status "Agent binaries made executable"
fi
# Run database migrations with self-healing
print_info "Running database migrations..."
cd "$instance_dir/backend"