Compare commits

...

10 Commits

Author SHA1 Message Date
9 Technology Group LTD
913976b7f6 1.3.2 final
fixed theme and user profile settings
2025-10-31 22:25:22 +00:00
Muhammad Ibrahim
53ff3bb1e2 fixed theme and user profile settings 2025-10-31 22:17:24 +00:00
9 Technology Group LTD
428207bc58 Merge pull request #262 from PatchMon/release/1-3-2
Fixed TFA fingerprint sending in CORS
2025-10-31 20:57:59 +00:00
Muhammad Ibrahim
1547af6986 Fixed TFA fingerprint sending in CORS
Ammended firstname and lastname adding issue in profile
2025-10-31 20:50:01 +00:00
9 Technology Group LTD
39fbafe01f Merge pull request #261 from PatchMon/release/1-3-1
Added migration file for the theme preferences
2025-10-31 18:18:47 +00:00
Muhammad Ibrahim
f296cf2003 Added migration file for the theme preferences 2025-10-31 18:17:48 +00:00
9 Technology Group LTD
052a77dce8 Merge pull request #260 from PatchMon/release/1-3-2
Release/1 3 2
2025-10-31 17:46:58 +00:00
Muhammad Ibrahim
94bfffd882 Theme settings per user 2025-10-31 17:33:47 +00:00
Muhammad Ibrahim
37462f4831 New 1.3.2 Agent 2025-10-31 15:41:01 +00:00
Muhammad Ibrahim
5457a1e9bc Docker implementation
Profile fixes
Hostgroup fixes
TFA fixes
2025-10-31 15:24:53 +00:00
46 changed files with 4059 additions and 999 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.1",
"version": "1.3.2",
"description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0",
"main": "src/server.js",

View File

@@ -0,0 +1,74 @@
-- 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

@@ -0,0 +1,6 @@
-- 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,6 +114,8 @@ 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])
@@ -194,7 +196,6 @@ model settings {
metrics_enabled Boolean @default(true)
metrics_anonymous_id String?
metrics_last_sent DateTime?
color_theme String @default("default")
}
model update_history {
@@ -226,6 +227,8 @@ 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[]
@@ -342,6 +345,56 @@ 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,6 +17,7 @@ const {
refresh_access_token,
revoke_session,
revoke_all_user_sessions,
generate_device_fingerprint,
} = require("../utils/session_manager");
const router = express.Router();
@@ -788,11 +789,39 @@ router.post(
// Check if TFA is enabled
if (user.tfa_enabled) {
return res.status(200).json({
message: "TFA verification required",
requiresTfa: true,
username: user.username,
});
// 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,
});
}
}
// Update last login
@@ -807,7 +836,13 @@ 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);
const session = await create_session(
user.id,
ip_address,
user_agent,
false,
req,
);
res.json({
message: "Login successful",
@@ -825,6 +860,9 @@ 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) {
@@ -841,8 +879,10 @@ router.post(
body("username").notEmpty().withMessage("Username is required"),
body("token")
.isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 digits"),
body("token").isNumeric().withMessage("Token must contain only numbers"),
.withMessage("Token must be 6 characters"),
body("token")
.matches(/^[A-Z0-9]{6}$/)
.withMessage("Token must be 6 alphanumeric characters"),
body("remember_me")
.optional()
.isBoolean()
@@ -915,10 +955,24 @@ router.post(
return res.status(401).json({ error: "Invalid verification code" });
}
// Update last login
await prisma.users.update({
// Update last login and fetch complete user data
const updatedUser = 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
@@ -938,14 +992,7 @@ router.post(
refresh_token: session.refresh_token,
expires_at: session.expires_at,
tfa_bypass_until: session.tfa_bypass_until,
user: {
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
},
user: updatedUser,
});
} catch (error) {
console.error("TFA verification error:", error);
@@ -977,13 +1024,27 @@ router.put(
.withMessage("Username must be at least 3 characters"),
body("email").optional().isEmail().withMessage("Valid email is required"),
body("first_name")
.optional()
.isLength({ min: 1 })
.withMessage("First name must be at least 1 character"),
.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"),
body("last_name")
.optional()
.isLength({ min: 1 })
.withMessage("Last name must be at least 1 character"),
.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"),
],
async (req, res) => {
try {
@@ -993,12 +1054,27 @@ router.put(
}
const { username, email, first_name, last_name } = req.body;
const updateData = {};
const updateData = {
updated_at: new Date(),
};
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;
// 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;
}
// Check if username/email already exists (excluding current user)
if (username || email) {
@@ -1023,6 +1099,7 @@ router.put(
}
}
// Update user with explicit commit
const updatedUser = await prisma.users.update({
where: { id: req.user.id },
data: updateData,
@@ -1039,9 +1116,29 @@ 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: updatedUser,
user: responseUser,
});
} catch (error) {
console.error("Update profile error:", error);

View File

@@ -573,6 +573,7 @@ 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,
},
});
@@ -822,6 +823,7 @@ 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,
},
});
@@ -876,6 +878,12 @@ 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: {
@@ -889,6 +897,7 @@ 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,
},
@@ -901,8 +910,9 @@ router.post("/../integrations/docker", async (req, res) => {
size_bytes: imageData.size_bytes
? BigInt(imageData.size_bytes)
: null,
source: imageData.source || "docker-hub",
source: imageSource,
created_at: parseDate(imageData.created_at),
last_checked: now,
updated_at: now,
},
});
@@ -1062,6 +1072,172 @@ 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 {
@@ -1093,4 +1269,66 @@ 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,7 +24,15 @@ router.get("/", authenticateToken, async (_req, res) => {
},
});
res.json(hostGroups);
// Transform the count field to match frontend expectations
const transformedGroups = hostGroups.map((group) => ({
...group,
_count: {
hosts: group._count.host_group_memberships,
},
}));
res.json(transformedGroups);
} catch (error) {
console.error("Error fetching host groups:", error);
res.status(500).json({ error: "Failed to fetch host groups" });

View File

@@ -10,6 +10,7 @@ const {
requireManageHosts,
requireManageSettings,
} = require("../middleware/permissions");
const { queueManager, QUEUE_NAMES } = require("../services/automation");
const router = express.Router();
const prisma = getPrismaClient();
@@ -1387,6 +1388,66 @@ 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",
@@ -1448,21 +1509,17 @@ router.post(
return res.status(404).json({ error: "Host not found" });
}
// Get queue manager
const { QUEUE_NAMES } = require("../services/automation");
const queueManager = req.app.locals.queueManager;
if (!queueManager) {
return res.status(500).json({
error: "Queue manager not available",
});
}
// 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
await queue.add(
const job = await queue.add(
"update_agent",
{
api_id: host.api_id,
@@ -1480,6 +1537,7 @@ router.post(
res.json({
success: true,
message: "Agent update queued successfully",
jobId: job.id,
host: {
id: host.id,
friendlyName: host.friendly_name,

View File

@@ -13,6 +13,8 @@ router.post("/docker", async (req, res) => {
const {
containers,
images,
volumes,
networks,
updates,
daemon_info: _daemon_info,
hostname,
@@ -49,6 +51,8 @@ router.post("/docker", async (req, res) => {
let containersProcessed = 0;
let imagesProcessed = 0;
let volumesProcessed = 0;
let networksProcessed = 0;
let updatesProcessed = 0;
// Process containers
@@ -169,6 +173,114 @@ 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`);
@@ -219,13 +331,15 @@ router.post("/docker", async (req, res) => {
}
console.log(
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${volumesProcessed} volumes, ${networksProcessed} networks, ${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,8 +261,10 @@ router.post(
body("username").notEmpty().withMessage("Username is required"),
body("token")
.isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 digits"),
body("token").isNumeric().withMessage("Token must contain only numbers"),
.withMessage("Token must be 6 characters"),
body("token")
.matches(/^[A-Z0-9]{6}$/)
.withMessage("Token must be 6 alphanumeric characters"),
],
async (req, res) => {
try {

View File

@@ -0,0 +1,105 @@
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,6 +70,7 @@ 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");
@@ -386,6 +387,7 @@ app.use(
"Authorization",
"Cookie",
"X-Requested-With",
"X-Device-ID", // Allow device ID header for TFA remember-me functionality
],
}),
);
@@ -477,6 +479,7 @@ 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;
@@ -556,299 +559,6 @@ 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

@@ -0,0 +1,343 @@
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,6 +2,7 @@ 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");
@@ -9,6 +10,7 @@ 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
@@ -18,6 +20,7 @@ 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",
};
@@ -97,6 +100,8 @@ 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,
);
@@ -167,6 +172,15 @@ 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,
@@ -183,28 +197,87 @@ class QueueManager {
const { api_id, type } = job.data;
console.log(`Processing agent command: ${type} for ${api_id}`);
// 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.`,
);
// 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})`);
}
} else {
console.error(`Unknown agent command type: ${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;
}
},
workerOptions,
@@ -234,6 +307,7 @@ class QueueManager {
console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`);
});
}
console.log("✅ Queue events initialized");
}
@@ -246,6 +320,7 @@ 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();
}
@@ -276,6 +351,12 @@ 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();
}

179
backend/src/utils/docker.js Normal file
View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.3.1",
"version": "1.3.2",
"license": "AGPL-3.0",
"type": "module",
"scripts": {

View File

@@ -8,6 +8,7 @@ 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";
@@ -28,6 +29,8 @@ 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"));
@@ -194,6 +197,26 @@ 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={
@@ -427,17 +450,19 @@ function AppRoutes() {
function App() {
return (
<ThemeProvider>
<ColorThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</AuthProvider>
</ColorThemeProvider>
</ThemeProvider>
<AuthProvider>
<ThemeProvider>
<SettingsProvider>
<ColorThemeProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</ColorThemeProvider>
</SettingsProvider>
</ThemeProvider>
</AuthProvider>
);
}

View File

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

View File

@@ -1,14 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
Image,
Palette,
RotateCcw,
Upload,
X,
} from "lucide-react";
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
import { useState } from "react";
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
import { settingsAPI } from "../../utils/api";
const BrandingTab = () => {
@@ -20,7 +12,6 @@ const BrandingTab = () => {
});
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
const [selectedLogoType, setSelectedLogoType] = useState("dark");
const { colorTheme, setColorTheme } = useColorTheme();
const queryClient = useQueryClient();
@@ -84,22 +75,6 @@ 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">
@@ -137,93 +112,11 @@ const BrandingTab = () => {
</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
Customize your PatchMon installation with custom logos, favicon, and
color themes. These will be displayed throughout the application.
Customize your PatchMon installation with custom logos and favicon.
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,10 +91,29 @@ 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 }),
});
@@ -119,6 +138,9 @@ 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)
@@ -205,8 +227,19 @@ 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,4 +1,15 @@
import { createContext, useContext, useEffect, useState } from "react";
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";
const ColorThemeContext = createContext();
@@ -121,62 +132,108 @@ export const THEME_PRESETS = {
};
export const ColorThemeProvider = ({ children }) => {
const [colorTheme, setColorTheme] = useState("default");
const [isLoading, setIsLoading] = useState(true);
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);
// Fetch theme from settings on mount
useEffect(() => {
const fetchTheme = async () => {
// 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)
try {
// Check localStorage first for unauthenticated pages (login)
const cachedTheme = localStorage.getItem("colorTheme");
if (cachedTheme) {
setColorTheme(cachedTheme);
}
await userPreferencesAPI.update({ color_theme: theme });
// 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");
}
// Invalidate and refetch user preferences to ensure sync across tabs/browsers
await queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
} catch (error) {
console.error("Error loading color theme:", error);
} finally {
setIsLoading(false);
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
}
};
},
[colorTheme, queryClient],
);
fetchTheme();
}, []);
// Memoize themeConfig to prevent unnecessary re-renders
const themeConfig = useMemo(
() => THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
[colorTheme],
);
const updateColorTheme = (theme) => {
setColorTheme(theme);
localStorage.setItem("colorTheme", theme);
};
const value = {
colorTheme,
setColorTheme: updateColorTheme,
themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
isLoading,
};
// Memoize the context value to prevent unnecessary re-renders
const value = useMemo(
() => ({
colorTheme,
setColorTheme: updateColorTheme,
themeConfig,
isLoading,
}),
[colorTheme, themeConfig, isLoading, updateColorTheme],
);
return (
<ColorThemeContext.Provider value={value}>

View File

@@ -0,0 +1,45 @@
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,4 +1,7 @@
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();
@@ -12,7 +15,7 @@ export const useTheme = () => {
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => {
// Check localStorage first, then system preference
// Check localStorage first for immediate render
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
return savedTheme;
@@ -24,6 +27,30 @@ 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") {
@@ -36,8 +63,17 @@ export const ThemeProvider = ({ children }) => {
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
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 value = {

View File

@@ -1,8 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useState } from "react";
import { isAuthReady } from "../constants/authPhases";
import { settingsAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
import { useSettings } from "./SettingsContext";
const UpdateNotificationContext = createContext();
@@ -18,17 +15,7 @@ export const useUpdateNotification = () => {
export const UpdateNotificationProvider = ({ children }) => {
const [dismissed, setDismissed] = useState(false);
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()),
});
const { settings, isLoading: settingsLoading } = useSettings();
// 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,6 +12,7 @@ import {
Copy,
Cpu,
Database,
Download,
Eye,
EyeOff,
HardDrive,
@@ -53,6 +54,8 @@ 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,
@@ -191,9 +194,50 @@ const HostDetail = () => {
const forceAgentUpdateMutation = useMutation({
mutationFn: () =>
adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data),
onSuccess: () => {
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);
},
});
@@ -409,20 +453,53 @@ 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 gap-2 text-sm"
className={`btn-outline flex items-center text-sm ${
host?.machine_id ? "justify-center p-2" : "gap-2"
}`}
title="View credentials"
>
<Key className="h-4 w-4" />
Deploy Agent
{!host?.machine_id && <span>Deploy Agent</span>}
</button>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center justify-center p-2 text-sm"
title="Refresh host data"
title="Refresh dashboard"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
@@ -716,12 +793,20 @@ const HostDetail = () => {
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Force Update
Force Agent Version Upgrade
</p>
<button
type="button"
onClick={() => forceAgentUpdateMutation.mutate()}
disabled={forceAgentUpdateMutation.isPending}
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
@@ -733,8 +818,20 @@ const HostDetail = () => {
/>
{forceAgentUpdateMutation.isPending
? "Updating..."
: "Update Now"}
: 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,9 +470,18 @@ 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">
<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="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" />
@@ -494,12 +503,30 @@ 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">
<p className="text-sm text-warning-800 mb-2">
<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,12 +531,11 @@ const Hosts = () => {
"with new data:",
data.host,
);
// Ensure hostGroupId is set correctly
// Host already has host_group_memberships from backend
const updatedHost = {
...data.host,
hostGroupId: data.host.host_groups?.id || null,
};
console.log("Updated host with hostGroupId:", updatedHost);
console.log("Updated host in cache:", updatedHost);
return updatedHost;
}
return host;
@@ -654,11 +653,15 @@ const Hosts = () => {
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
// Group filter
// Group filter - handle multiple groups per host
const memberships = host.host_group_memberships || [];
const matchesGroup =
groupFilter === "all" ||
(groupFilter === "ungrouped" && !host.host_groups) ||
(groupFilter !== "ungrouped" && host.host_groups?.id === groupFilter);
(groupFilter === "ungrouped" && memberships.length === 0) ||
(groupFilter !== "ungrouped" &&
memberships.some(
(membership) => membership.host_groups?.id === groupFilter,
));
// Status filter
const matchesStatus =
@@ -711,10 +714,30 @@ const Hosts = () => {
aValue = a.ip?.toLowerCase() || "zzz_no_ip";
bValue = b.ip?.toLowerCase() || "zzz_no_ip";
break;
case "group":
aValue = a.host_groups?.name || "zzz_ungrouped";
bValue = b.host_groups?.name || "zzz_ungrouped";
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";
}
break;
}
case "os":
aValue = a.os_type?.toLowerCase() || "zzz_unknown";
bValue = b.os_type?.toLowerCase() || "zzz_unknown";
@@ -787,27 +810,46 @@ const Hosts = () => {
const groups = {};
filteredAndSortedHosts.forEach((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 (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] = [];
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(host);
}
groups[groupKey].push(host);
});
return groups;
@@ -1394,14 +1436,6 @@ 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

@@ -248,7 +248,8 @@ const Login = () => {
} catch (error) {
console.error("Failed to fetch GitHub data:", error);
// Set fallback data if nothing cached
if (!latestRelease) {
const cachedRelease = localStorage.getItem("githubLatestRelease");
if (!cachedRelease) {
setLatestRelease({
version: "v1.3.0",
name: "Latest Release",
@@ -260,7 +261,7 @@ const Login = () => {
};
fetchGitHubData();
}, [latestRelease]);
}, []); // Run once on mount
const handleSubmit = async (e) => {
e.preventDefault();
@@ -407,7 +408,12 @@ const Login = () => {
setTfaData({
...tfaData,
[name]:
type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6),
type === "checkbox"
? checked
: value
.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.slice(0, 6),
});
// Clear error when user starts typing
if (error) {
@@ -872,7 +878,8 @@ const Login = () => {
Two-Factor Authentication
</h3>
<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400">
Enter the 6-digit code from your authenticator app
Enter the code from your authenticator app, or use a backup
code
</p>
</div>
@@ -891,11 +898,15 @@ 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"
placeholder="000000"
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"
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">
@@ -955,12 +966,6 @@ 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,9 +557,18 @@ 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">
<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="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" />
@@ -581,12 +590,30 @@ 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">
<p className="text-sm text-warning-800 mb-2">
<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">
Total Packages
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">
Total Installations
Installations
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{totalInstallationsCount}
@@ -562,47 +562,72 @@ const Packages = () => {
</div>
</div>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<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="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">
Total Outdated Packages
Outdated Packages
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{outdatedPackagesCount}
</p>
</div>
</div>
</div>
</button>
<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">
Hosts Pending Updates
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniquePackageHostsCount}
</p>
</div>
</div>
</div>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<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 Updates Across All Hosts
Security Packages
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{securityUpdatesCount}
</p>
</div>
</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="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
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniquePackageHostsCount}
</p>
</div>
</div>
</button>
</div>
{/* Packages List */}

View File

@@ -25,6 +25,7 @@ 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";
@@ -38,6 +39,7 @@ 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: "" });
@@ -78,8 +80,10 @@ 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 {
@@ -411,6 +415,68 @@ 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">
@@ -564,6 +630,7 @@ 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

@@ -0,0 +1,483 @@
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

@@ -0,0 +1,359 @@
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,239 +746,126 @@ const Integrations = () => {
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Docker Container Monitoring
Docker Inventory Collection
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
Monitor Docker containers and images for available updates
Docker monitoring is now built into the PatchMon Go agent
</p>
</div>
</div>
{/* Installation Instructions */}
{/* Info Message */}
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
<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">
<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">
<li>
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>
)
Install the PatchMon Go agent on your host (see the Hosts
page for installation instructions)
</li>
<li>
SSH into your Docker host where you want to monitor
containers
The agent automatically detects if Docker is installed and
running on the host
</li>
<li>Run the installation command below</li>
<li>
The agent will automatically collect Docker container and
image information every 5 minutes
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
</li>
<li>View your Docker inventory in the Docker page</li>
</ol>
</div>
{/* 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>
{/* 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">
{/* 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">
<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>
<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.
</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">
<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>
<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>
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
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
</code>
)
</li>
<li>
The host must have network access to your PatchMon
server
Typically requires running the agent as root or with
Docker group permissions
</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} host
{group._count.hosts !== 1 ? "s" : ""}
{group._count?.hosts || 0} host
{group._count?.hosts !== 1 ? "s" : ""}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
@@ -539,9 +539,18 @@ 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">
<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="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" />
@@ -561,14 +570,32 @@ 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">
<strong>Note:</strong> This group contains {group._count.hosts}{" "}
<p className="text-sm text-blue-800 mb-2">
<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,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BarChart3,
BookOpen,
CheckCircle,
Eye,
EyeOff,
@@ -178,6 +179,19 @@ 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,6 +19,30 @@ 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) => {
@@ -96,6 +120,7 @@ export const adminHostsAPI = {
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,
@@ -144,6 +169,12 @@ 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

@@ -0,0 +1,171 @@
/**
* 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;
}

View File

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

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.0-selfhost-2025-10-19-1"
SCRIPT_VERSION="self-hosting-install.sh v1.3.2-selfhost-2025-10-31-1"
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
FQDN=""
CUSTOM_FQDN=""
@@ -2197,34 +2197,66 @@ select_installation_to_update() {
version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
fi
# 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 '.' '_')"
# Get service status - search for service files that reference this installation
local service_name=""
local status="unknown"
# 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"
# 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"
fi
printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
@@ -3072,11 +3104,16 @@ 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
git clean -fd 2>/dev/null || true
# 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..."
git reset --hard HEAD
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
# Fetch latest changes
git fetch origin