mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-05 14:35:35 +00:00
Compare commits
1 Commits
v1.3.2
...
renovate/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1f506ae9d |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-backend",
|
"name": "patchmon-backend",
|
||||||
"version": "1.3.2",
|
"version": "1.3.1",
|
||||||
"description": "Backend API for Linux Patch Monitoring System",
|
"description": "Backend API for Linux Patch Monitoring System",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "docker_volumes" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"host_id" TEXT NOT NULL,
|
|
||||||
"volume_id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"driver" TEXT NOT NULL,
|
|
||||||
"mountpoint" TEXT,
|
|
||||||
"renderer" TEXT,
|
|
||||||
"scope" TEXT NOT NULL DEFAULT 'local',
|
|
||||||
"labels" JSONB,
|
|
||||||
"options" JSONB,
|
|
||||||
"size_bytes" BIGINT,
|
|
||||||
"ref_count" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "docker_volumes_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "docker_networks" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"host_id" TEXT NOT NULL,
|
|
||||||
"network_id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"driver" TEXT NOT NULL,
|
|
||||||
"scope" TEXT NOT NULL DEFAULT 'local',
|
|
||||||
"ipv6_enabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"internal" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"attachable" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"ingress" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"config_only" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"labels" JSONB,
|
|
||||||
"ipam" JSONB,
|
|
||||||
"container_count" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"created_at" TIMESTAMP(3),
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "docker_networks_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "docker_volumes_host_id_idx" ON "docker_volumes"("host_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "docker_volumes_name_idx" ON "docker_volumes"("name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "docker_volumes_driver_idx" ON "docker_volumes"("driver");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "docker_volumes_host_id_volume_id_key" ON "docker_volumes"("host_id", "volume_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "docker_networks_host_id_idx" ON "docker_networks"("host_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "docker_networks_name_idx" ON "docker_networks"("name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "docker_networks_driver_idx" ON "docker_networks"("driver");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "docker_networks_host_id_network_id_key" ON "docker_networks"("host_id", "network_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "docker_volumes" ADD CONSTRAINT "docker_volumes_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "docker_networks" ADD CONSTRAINT "docker_networks_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "theme_preference" VARCHAR(10) DEFAULT 'dark';
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "color_theme" VARCHAR(50) DEFAULT 'cyber_blue';
|
|
||||||
|
|
||||||
@@ -114,8 +114,6 @@ model hosts {
|
|||||||
host_group_memberships host_group_memberships[]
|
host_group_memberships host_group_memberships[]
|
||||||
update_history update_history[]
|
update_history update_history[]
|
||||||
job_history job_history[]
|
job_history job_history[]
|
||||||
docker_volumes docker_volumes[]
|
|
||||||
docker_networks docker_networks[]
|
|
||||||
|
|
||||||
@@index([machine_id])
|
@@index([machine_id])
|
||||||
@@index([friendly_name])
|
@@index([friendly_name])
|
||||||
@@ -196,6 +194,7 @@ model settings {
|
|||||||
metrics_enabled Boolean @default(true)
|
metrics_enabled Boolean @default(true)
|
||||||
metrics_anonymous_id String?
|
metrics_anonymous_id String?
|
||||||
metrics_last_sent DateTime?
|
metrics_last_sent DateTime?
|
||||||
|
color_theme String @default("default")
|
||||||
}
|
}
|
||||||
|
|
||||||
model update_history {
|
model update_history {
|
||||||
@@ -227,8 +226,6 @@ model users {
|
|||||||
tfa_secret String?
|
tfa_secret String?
|
||||||
first_name String?
|
first_name String?
|
||||||
last_name String?
|
last_name String?
|
||||||
theme_preference String? @default("dark")
|
|
||||||
color_theme String? @default("cyber_blue")
|
|
||||||
dashboard_preferences dashboard_preferences[]
|
dashboard_preferences dashboard_preferences[]
|
||||||
user_sessions user_sessions[]
|
user_sessions user_sessions[]
|
||||||
auto_enrollment_tokens auto_enrollment_tokens[]
|
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||||
@@ -345,56 +342,6 @@ model docker_image_updates {
|
|||||||
@@index([is_security_update])
|
@@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 {
|
model job_history {
|
||||||
id String @id
|
id String @id
|
||||||
job_id String
|
job_id String
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const {
|
|||||||
refresh_access_token,
|
refresh_access_token,
|
||||||
revoke_session,
|
revoke_session,
|
||||||
revoke_all_user_sessions,
|
revoke_all_user_sessions,
|
||||||
generate_device_fingerprint,
|
|
||||||
} = require("../utils/session_manager");
|
} = require("../utils/session_manager");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -789,39 +788,11 @@ router.post(
|
|||||||
|
|
||||||
// Check if TFA is enabled
|
// Check if TFA is enabled
|
||||||
if (user.tfa_enabled) {
|
if (user.tfa_enabled) {
|
||||||
// Get device fingerprint from X-Device-ID header
|
return res.status(200).json({
|
||||||
const device_fingerprint = generate_device_fingerprint(req);
|
message: "TFA verification required",
|
||||||
|
requiresTfa: true,
|
||||||
// Check if this device has a valid TFA bypass
|
username: user.username,
|
||||||
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
|
// Update last login
|
||||||
@@ -836,13 +807,7 @@ router.post(
|
|||||||
// Create session with access and refresh tokens
|
// Create session with access and refresh tokens
|
||||||
const ip_address = req.ip || req.connection.remoteAddress;
|
const ip_address = req.ip || req.connection.remoteAddress;
|
||||||
const user_agent = req.get("user-agent");
|
const user_agent = req.get("user-agent");
|
||||||
const session = await create_session(
|
const session = await create_session(user.id, ip_address, user_agent);
|
||||||
user.id,
|
|
||||||
ip_address,
|
|
||||||
user_agent,
|
|
||||||
false,
|
|
||||||
req,
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Login successful",
|
message: "Login successful",
|
||||||
@@ -860,9 +825,6 @@ router.post(
|
|||||||
last_login: user.last_login,
|
last_login: user.last_login,
|
||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
updated_at: user.updated_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) {
|
} catch (error) {
|
||||||
@@ -879,10 +841,8 @@ router.post(
|
|||||||
body("username").notEmpty().withMessage("Username is required"),
|
body("username").notEmpty().withMessage("Username is required"),
|
||||||
body("token")
|
body("token")
|
||||||
.isLength({ min: 6, max: 6 })
|
.isLength({ min: 6, max: 6 })
|
||||||
.withMessage("Token must be 6 characters"),
|
.withMessage("Token must be 6 digits"),
|
||||||
body("token")
|
body("token").isNumeric().withMessage("Token must contain only numbers"),
|
||||||
.matches(/^[A-Z0-9]{6}$/)
|
|
||||||
.withMessage("Token must be 6 alphanumeric characters"),
|
|
||||||
body("remember_me")
|
body("remember_me")
|
||||||
.optional()
|
.optional()
|
||||||
.isBoolean()
|
.isBoolean()
|
||||||
@@ -955,24 +915,10 @@ router.post(
|
|||||||
return res.status(401).json({ error: "Invalid verification code" });
|
return res.status(401).json({ error: "Invalid verification code" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last login and fetch complete user data
|
// Update last login
|
||||||
const updatedUser = await prisma.users.update({
|
await prisma.users.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { last_login: new Date() },
|
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
|
// Create session with access and refresh tokens
|
||||||
@@ -992,7 +938,14 @@ router.post(
|
|||||||
refresh_token: session.refresh_token,
|
refresh_token: session.refresh_token,
|
||||||
expires_at: session.expires_at,
|
expires_at: session.expires_at,
|
||||||
tfa_bypass_until: session.tfa_bypass_until,
|
tfa_bypass_until: session.tfa_bypass_until,
|
||||||
user: updatedUser,
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("TFA verification error:", error);
|
console.error("TFA verification error:", error);
|
||||||
@@ -1024,27 +977,13 @@ router.put(
|
|||||||
.withMessage("Username must be at least 3 characters"),
|
.withMessage("Username must be at least 3 characters"),
|
||||||
body("email").optional().isEmail().withMessage("Valid email is required"),
|
body("email").optional().isEmail().withMessage("Valid email is required"),
|
||||||
body("first_name")
|
body("first_name")
|
||||||
.optional({ nullable: true, checkFalsy: true })
|
.optional()
|
||||||
.custom((value) => {
|
.isLength({ min: 1 })
|
||||||
// Allow null, undefined, or empty string to clear the field
|
.withMessage("First name must be at least 1 character"),
|
||||||
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")
|
body("last_name")
|
||||||
.optional({ nullable: true, checkFalsy: true })
|
.optional()
|
||||||
.custom((value) => {
|
.isLength({ min: 1 })
|
||||||
// Allow null, undefined, or empty string to clear the field
|
.withMessage("Last name must be at least 1 character"),
|
||||||
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) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -1054,27 +993,12 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { username, email, first_name, last_name } = req.body;
|
const { username, email, first_name, last_name } = req.body;
|
||||||
const updateData = {
|
const updateData = {};
|
||||||
updated_at: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle all fields consistently - trim and update if provided
|
if (username) updateData.username = username;
|
||||||
if (username) updateData.username = username.trim();
|
if (email) updateData.email = email;
|
||||||
if (email) updateData.email = email.trim();
|
if (first_name !== undefined) updateData.first_name = first_name || null;
|
||||||
if (first_name !== undefined) {
|
if (last_name !== undefined) updateData.last_name = last_name || null;
|
||||||
// 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)
|
// Check if username/email already exists (excluding current user)
|
||||||
if (username || email) {
|
if (username || email) {
|
||||||
@@ -1099,7 +1023,6 @@ router.put(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user with explicit commit
|
|
||||||
const updatedUser = await prisma.users.update({
|
const updatedUser = await prisma.users.update({
|
||||||
where: { id: req.user.id },
|
where: { id: req.user.id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
@@ -1116,29 +1039,9 @@ router.put(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Explicitly refresh user data from database to ensure we return latest data
|
|
||||||
// This ensures consistency especially in high-concurrency scenarios
|
|
||||||
const freshUser = await prisma.users.findUnique({
|
|
||||||
where: { id: req.user.id },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
email: true,
|
|
||||||
first_name: true,
|
|
||||||
last_name: true,
|
|
||||||
role: true,
|
|
||||||
is_active: true,
|
|
||||||
last_login: true,
|
|
||||||
updated_at: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use fresh data if available, otherwise fallback to updatedUser
|
|
||||||
const responseUser = freshUser || updatedUser;
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Profile updated successfully",
|
message: "Profile updated successfully",
|
||||||
user: responseUser,
|
user: updatedUser,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Update profile error:", error);
|
console.error("Update profile error:", error);
|
||||||
|
|||||||
@@ -573,7 +573,6 @@ router.post("/collect", async (req, res) => {
|
|||||||
image_id: containerData.image_id || "unknown",
|
image_id: containerData.image_id || "unknown",
|
||||||
source: containerData.image_source || "docker-hub",
|
source: containerData.image_source || "docker-hub",
|
||||||
created_at: parseDate(containerData.created_at),
|
created_at: parseDate(containerData.created_at),
|
||||||
last_checked: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -823,7 +822,6 @@ router.post("/../integrations/docker", async (req, res) => {
|
|||||||
image_id: containerData.image_id || "unknown",
|
image_id: containerData.image_id || "unknown",
|
||||||
source: containerData.image_source || "docker-hub",
|
source: containerData.image_source || "docker-hub",
|
||||||
created_at: parseDate(containerData.created_at),
|
created_at: parseDate(containerData.created_at),
|
||||||
last_checked: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -878,12 +876,6 @@ router.post("/../integrations/docker", async (req, res) => {
|
|||||||
if (images && Array.isArray(images)) {
|
if (images && Array.isArray(images)) {
|
||||||
console.log(`[Docker Integration] Processing ${images.length} images`);
|
console.log(`[Docker Integration] Processing ${images.length} images`);
|
||||||
for (const imageData of 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({
|
await prisma.docker_images.upsert({
|
||||||
where: {
|
where: {
|
||||||
repository_tag_image_id: {
|
repository_tag_image_id: {
|
||||||
@@ -897,7 +889,6 @@ router.post("/../integrations/docker", async (req, res) => {
|
|||||||
? BigInt(imageData.size_bytes)
|
? BigInt(imageData.size_bytes)
|
||||||
: null,
|
: null,
|
||||||
digest: imageData.digest || null,
|
digest: imageData.digest || null,
|
||||||
source: imageSource, // Update source in case it changed
|
|
||||||
last_checked: now,
|
last_checked: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
@@ -910,9 +901,8 @@ router.post("/../integrations/docker", async (req, res) => {
|
|||||||
size_bytes: imageData.size_bytes
|
size_bytes: imageData.size_bytes
|
||||||
? BigInt(imageData.size_bytes)
|
? BigInt(imageData.size_bytes)
|
||||||
: null,
|
: null,
|
||||||
source: imageSource,
|
source: imageData.source || "docker-hub",
|
||||||
created_at: parseDate(imageData.created_at),
|
created_at: parseDate(imageData.created_at),
|
||||||
last_checked: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1072,172 +1062,6 @@ router.delete("/images/:id", authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/v1/docker/volumes - Get all volumes with filters
|
|
||||||
router.get("/volumes", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { driver, search, page = 1, limit = 50 } = req.query;
|
|
||||||
|
|
||||||
const where = {};
|
|
||||||
if (driver) where.driver = driver;
|
|
||||||
if (search) {
|
|
||||||
where.OR = [{ name: { contains: search, mode: "insensitive" } }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
|
||||||
const take = parseInt(limit, 10);
|
|
||||||
|
|
||||||
const [volumes, total] = await Promise.all([
|
|
||||||
prisma.docker_volumes.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
hosts: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
friendly_name: true,
|
|
||||||
hostname: true,
|
|
||||||
ip: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updated_at: "desc" },
|
|
||||||
skip,
|
|
||||||
take,
|
|
||||||
}),
|
|
||||||
prisma.docker_volumes.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json(
|
|
||||||
convertBigIntToString({
|
|
||||||
volumes,
|
|
||||||
pagination: {
|
|
||||||
page: parseInt(page, 10),
|
|
||||||
limit: parseInt(limit, 10),
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / parseInt(limit, 10)),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching volumes:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch volumes" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/v1/docker/volumes/:id - Get volume detail
|
|
||||||
router.get("/volumes/:id", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const volume = await prisma.docker_volumes.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
hosts: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
friendly_name: true,
|
|
||||||
hostname: true,
|
|
||||||
ip: true,
|
|
||||||
os_type: true,
|
|
||||||
os_version: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!volume) {
|
|
||||||
return res.status(404).json({ error: "Volume not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(convertBigIntToString({ volume }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching volume detail:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch volume detail" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/v1/docker/networks - Get all networks with filters
|
|
||||||
router.get("/networks", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { driver, search, page = 1, limit = 50 } = req.query;
|
|
||||||
|
|
||||||
const where = {};
|
|
||||||
if (driver) where.driver = driver;
|
|
||||||
if (search) {
|
|
||||||
where.OR = [{ name: { contains: search, mode: "insensitive" } }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
|
||||||
const take = parseInt(limit, 10);
|
|
||||||
|
|
||||||
const [networks, total] = await Promise.all([
|
|
||||||
prisma.docker_networks.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
hosts: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
friendly_name: true,
|
|
||||||
hostname: true,
|
|
||||||
ip: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updated_at: "desc" },
|
|
||||||
skip,
|
|
||||||
take,
|
|
||||||
}),
|
|
||||||
prisma.docker_networks.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json(
|
|
||||||
convertBigIntToString({
|
|
||||||
networks,
|
|
||||||
pagination: {
|
|
||||||
page: parseInt(page, 10),
|
|
||||||
limit: parseInt(limit, 10),
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / parseInt(limit, 10)),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching networks:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch networks" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/v1/docker/networks/:id - Get network detail
|
|
||||||
router.get("/networks/:id", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const network = await prisma.docker_networks.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
hosts: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
friendly_name: true,
|
|
||||||
hostname: true,
|
|
||||||
ip: true,
|
|
||||||
os_type: true,
|
|
||||||
os_version: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!network) {
|
|
||||||
return res.status(404).json({ error: "Network not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(convertBigIntToString({ network }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching network detail:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch network detail" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/v1/docker/agent - Serve the Docker agent installation script
|
// GET /api/v1/docker/agent - Serve the Docker agent installation script
|
||||||
router.get("/agent", async (_req, res) => {
|
router.get("/agent", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -1269,66 +1093,4 @@ router.get("/agent", async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/v1/docker/volumes/:id - Delete a volume
|
|
||||||
router.delete("/volumes/:id", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
// Check if volume exists
|
|
||||||
const volume = await prisma.docker_volumes.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!volume) {
|
|
||||||
return res.status(404).json({ error: "Volume not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the volume
|
|
||||||
await prisma.docker_volumes.delete({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`🗑️ Deleted volume: ${volume.name} (${id})`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Volume ${volume.name} deleted successfully`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting volume:", error);
|
|
||||||
res.status(500).json({ error: "Failed to delete volume" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /api/v1/docker/networks/:id - Delete a network
|
|
||||||
router.delete("/networks/:id", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
// Check if network exists
|
|
||||||
const network = await prisma.docker_networks.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!network) {
|
|
||||||
return res.status(404).json({ error: "Network not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the network
|
|
||||||
await prisma.docker_networks.delete({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`🗑️ Deleted network: ${network.name} (${id})`);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Network ${network.name} deleted successfully`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting network:", error);
|
|
||||||
res.status(500).json({ error: "Failed to delete network" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -24,15 +24,7 @@ router.get("/", authenticateToken, async (_req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Transform the count field to match frontend expectations
|
res.json(hostGroups);
|
||||||
const transformedGroups = hostGroups.map((group) => ({
|
|
||||||
...group,
|
|
||||||
_count: {
|
|
||||||
hosts: group._count.host_group_memberships,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json(transformedGroups);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching host groups:", error);
|
console.error("Error fetching host groups:", error);
|
||||||
res.status(500).json({ error: "Failed to fetch host groups" });
|
res.status(500).json({ error: "Failed to fetch host groups" });
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const {
|
|||||||
requireManageHosts,
|
requireManageHosts,
|
||||||
requireManageSettings,
|
requireManageSettings,
|
||||||
} = require("../middleware/permissions");
|
} = require("../middleware/permissions");
|
||||||
const { queueManager, QUEUE_NAMES } = require("../services/automation");
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = getPrismaClient();
|
const prisma = getPrismaClient();
|
||||||
@@ -1388,66 +1387,6 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Force immediate report from agent
|
|
||||||
router.post(
|
|
||||||
"/:hostId/fetch-report",
|
|
||||||
authenticateToken,
|
|
||||||
requireManageHosts,
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { hostId } = req.params;
|
|
||||||
|
|
||||||
// Get host to verify it exists
|
|
||||||
const host = await prisma.hosts.findUnique({
|
|
||||||
where: { id: hostId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!host) {
|
|
||||||
return res.status(404).json({ error: "Host not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the agent-commands queue
|
|
||||||
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
|
|
||||||
|
|
||||||
if (!queue) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: "Queue not available",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add job to queue
|
|
||||||
const job = await queue.add(
|
|
||||||
"report_now",
|
|
||||||
{
|
|
||||||
api_id: host.api_id,
|
|
||||||
type: "report_now",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attempts: 3,
|
|
||||||
backoff: {
|
|
||||||
type: "exponential",
|
|
||||||
delay: 2000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: "Report fetch queued successfully",
|
|
||||||
jobId: job.id,
|
|
||||||
host: {
|
|
||||||
id: host.id,
|
|
||||||
friendlyName: host.friendly_name,
|
|
||||||
apiId: host.api_id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Force fetch report error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch report" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Toggle agent auto-update setting
|
// Toggle agent auto-update setting
|
||||||
router.patch(
|
router.patch(
|
||||||
"/:hostId/auto-update",
|
"/:hostId/auto-update",
|
||||||
@@ -1491,66 +1430,6 @@ router.patch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Force agent update for specific host
|
|
||||||
router.post(
|
|
||||||
"/:hostId/force-agent-update",
|
|
||||||
authenticateToken,
|
|
||||||
requireManageHosts,
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { hostId } = req.params;
|
|
||||||
|
|
||||||
// Get host to verify it exists
|
|
||||||
const host = await prisma.hosts.findUnique({
|
|
||||||
where: { id: hostId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!host) {
|
|
||||||
return res.status(404).json({ error: "Host not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the agent-commands queue
|
|
||||||
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
|
|
||||||
|
|
||||||
if (!queue) {
|
|
||||||
return res.status(500).json({
|
|
||||||
error: "Queue not available",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add job to queue
|
|
||||||
const job = await queue.add(
|
|
||||||
"update_agent",
|
|
||||||
{
|
|
||||||
api_id: host.api_id,
|
|
||||||
type: "update_agent",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attempts: 3,
|
|
||||||
backoff: {
|
|
||||||
type: "exponential",
|
|
||||||
delay: 2000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: "Agent update queued successfully",
|
|
||||||
jobId: job.id,
|
|
||||||
host: {
|
|
||||||
id: host.id,
|
|
||||||
friendlyName: host.friendly_name,
|
|
||||||
apiId: host.api_id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Force agent update error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to force agent update" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Serve the installation script (requires API authentication)
|
// Serve the installation script (requires API authentication)
|
||||||
router.get("/install", async (req, res) => {
|
router.get("/install", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ router.post("/docker", async (req, res) => {
|
|||||||
const {
|
const {
|
||||||
containers,
|
containers,
|
||||||
images,
|
images,
|
||||||
volumes,
|
|
||||||
networks,
|
|
||||||
updates,
|
updates,
|
||||||
daemon_info: _daemon_info,
|
daemon_info: _daemon_info,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -51,8 +49,6 @@ router.post("/docker", async (req, res) => {
|
|||||||
|
|
||||||
let containersProcessed = 0;
|
let containersProcessed = 0;
|
||||||
let imagesProcessed = 0;
|
let imagesProcessed = 0;
|
||||||
let volumesProcessed = 0;
|
|
||||||
let networksProcessed = 0;
|
|
||||||
let updatesProcessed = 0;
|
let updatesProcessed = 0;
|
||||||
|
|
||||||
// Process containers
|
// Process containers
|
||||||
@@ -173,114 +169,6 @@ router.post("/docker", async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process volumes
|
|
||||||
if (volumes && Array.isArray(volumes)) {
|
|
||||||
console.log(`[Docker Integration] Processing ${volumes.length} volumes`);
|
|
||||||
for (const volumeData of volumes) {
|
|
||||||
await prisma.docker_volumes.upsert({
|
|
||||||
where: {
|
|
||||||
host_id_volume_id: {
|
|
||||||
host_id: host.id,
|
|
||||||
volume_id: volumeData.volume_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
name: volumeData.name,
|
|
||||||
driver: volumeData.driver || "local",
|
|
||||||
mountpoint: volumeData.mountpoint || null,
|
|
||||||
renderer: volumeData.renderer || null,
|
|
||||||
scope: volumeData.scope || "local",
|
|
||||||
labels: volumeData.labels || null,
|
|
||||||
options: volumeData.options || null,
|
|
||||||
size_bytes: volumeData.size_bytes
|
|
||||||
? BigInt(volumeData.size_bytes)
|
|
||||||
: null,
|
|
||||||
ref_count: volumeData.ref_count || 0,
|
|
||||||
updated_at: now,
|
|
||||||
last_checked: now,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: uuidv4(),
|
|
||||||
host_id: host.id,
|
|
||||||
volume_id: volumeData.volume_id,
|
|
||||||
name: volumeData.name,
|
|
||||||
driver: volumeData.driver || "local",
|
|
||||||
mountpoint: volumeData.mountpoint || null,
|
|
||||||
renderer: volumeData.renderer || null,
|
|
||||||
scope: volumeData.scope || "local",
|
|
||||||
labels: volumeData.labels || null,
|
|
||||||
options: volumeData.options || null,
|
|
||||||
size_bytes: volumeData.size_bytes
|
|
||||||
? BigInt(volumeData.size_bytes)
|
|
||||||
: null,
|
|
||||||
ref_count: volumeData.ref_count || 0,
|
|
||||||
created_at: parseDate(volumeData.created_at),
|
|
||||||
updated_at: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
volumesProcessed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process networks
|
|
||||||
if (networks && Array.isArray(networks)) {
|
|
||||||
console.log(
|
|
||||||
`[Docker Integration] Processing ${networks.length} networks`,
|
|
||||||
);
|
|
||||||
for (const networkData of networks) {
|
|
||||||
await prisma.docker_networks.upsert({
|
|
||||||
where: {
|
|
||||||
host_id_network_id: {
|
|
||||||
host_id: host.id,
|
|
||||||
network_id: networkData.network_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
name: networkData.name,
|
|
||||||
driver: networkData.driver,
|
|
||||||
scope: networkData.scope || "local",
|
|
||||||
ipv6_enabled: networkData.ipv6_enabled || false,
|
|
||||||
internal: networkData.internal || false,
|
|
||||||
attachable:
|
|
||||||
networkData.attachable !== undefined
|
|
||||||
? networkData.attachable
|
|
||||||
: true,
|
|
||||||
ingress: networkData.ingress || false,
|
|
||||||
config_only: networkData.config_only || false,
|
|
||||||
labels: networkData.labels || null,
|
|
||||||
ipam: networkData.ipam || null,
|
|
||||||
container_count: networkData.container_count || 0,
|
|
||||||
updated_at: now,
|
|
||||||
last_checked: now,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: uuidv4(),
|
|
||||||
host_id: host.id,
|
|
||||||
network_id: networkData.network_id,
|
|
||||||
name: networkData.name,
|
|
||||||
driver: networkData.driver,
|
|
||||||
scope: networkData.scope || "local",
|
|
||||||
ipv6_enabled: networkData.ipv6_enabled || false,
|
|
||||||
internal: networkData.internal || false,
|
|
||||||
attachable:
|
|
||||||
networkData.attachable !== undefined
|
|
||||||
? networkData.attachable
|
|
||||||
: true,
|
|
||||||
ingress: networkData.ingress || false,
|
|
||||||
config_only: networkData.config_only || false,
|
|
||||||
labels: networkData.labels || null,
|
|
||||||
ipam: networkData.ipam || null,
|
|
||||||
container_count: networkData.container_count || 0,
|
|
||||||
created_at: networkData.created_at
|
|
||||||
? parseDate(networkData.created_at)
|
|
||||||
: null,
|
|
||||||
updated_at: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
networksProcessed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process updates
|
// Process updates
|
||||||
if (updates && Array.isArray(updates)) {
|
if (updates && Array.isArray(updates)) {
|
||||||
console.log(`[Docker Integration] Processing ${updates.length} updates`);
|
console.log(`[Docker Integration] Processing ${updates.length} updates`);
|
||||||
@@ -331,15 +219,13 @@ router.post("/docker", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${volumesProcessed} volumes, ${networksProcessed} networks, ${updatesProcessed} updates`,
|
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Docker data collected successfully",
|
message: "Docker data collected successfully",
|
||||||
containers_received: containersProcessed,
|
containers_received: containersProcessed,
|
||||||
images_received: imagesProcessed,
|
images_received: imagesProcessed,
|
||||||
volumes_received: volumesProcessed,
|
|
||||||
networks_received: networksProcessed,
|
|
||||||
updates_found: updatesProcessed,
|
updates_found: updatesProcessed,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -261,10 +261,8 @@ router.post(
|
|||||||
body("username").notEmpty().withMessage("Username is required"),
|
body("username").notEmpty().withMessage("Username is required"),
|
||||||
body("token")
|
body("token")
|
||||||
.isLength({ min: 6, max: 6 })
|
.isLength({ min: 6, max: 6 })
|
||||||
.withMessage("Token must be 6 characters"),
|
.withMessage("Token must be 6 digits"),
|
||||||
body("token")
|
body("token").isNumeric().withMessage("Token must contain only numbers"),
|
||||||
.matches(/^[A-Z0-9]{6}$/)
|
|
||||||
.withMessage("Token must be 6 alphanumeric characters"),
|
|
||||||
],
|
],
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
const express = require("express");
|
|
||||||
const { getPrismaClient } = require("../config/prisma");
|
|
||||||
const { authenticateToken } = require("../middleware/auth");
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
const prisma = getPrismaClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/v1/user/preferences
|
|
||||||
* Get current user's preferences (theme and color theme)
|
|
||||||
*/
|
|
||||||
router.get("/", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
|
|
||||||
const user = await prisma.users.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: {
|
|
||||||
theme_preference: true,
|
|
||||||
color_theme: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(404).json({ error: "User not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
theme_preference: user.theme_preference || "dark",
|
|
||||||
color_theme: user.color_theme || "cyber_blue",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching user preferences:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch user preferences" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /api/v1/user/preferences
|
|
||||||
* Update current user's preferences
|
|
||||||
*/
|
|
||||||
router.patch("/", authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = req.user.id;
|
|
||||||
const { theme_preference, color_theme } = req.body;
|
|
||||||
|
|
||||||
// Validate inputs
|
|
||||||
const updateData = {};
|
|
||||||
if (theme_preference !== undefined) {
|
|
||||||
if (!["light", "dark"].includes(theme_preference)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: "Invalid theme preference. Must be 'light' or 'dark'",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updateData.theme_preference = theme_preference;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (color_theme !== undefined) {
|
|
||||||
const validColorThemes = [
|
|
||||||
"default",
|
|
||||||
"cyber_blue",
|
|
||||||
"neon_purple",
|
|
||||||
"matrix_green",
|
|
||||||
"ocean_blue",
|
|
||||||
"sunset_gradient",
|
|
||||||
];
|
|
||||||
if (!validColorThemes.includes(color_theme)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: `Invalid color theme. Must be one of: ${validColorThemes.join(", ")}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updateData.color_theme = color_theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updateData).length === 0) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "No preferences provided to update" });
|
|
||||||
}
|
|
||||||
|
|
||||||
updateData.updated_at = new Date();
|
|
||||||
|
|
||||||
const updatedUser = await prisma.users.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: updateData,
|
|
||||||
select: {
|
|
||||||
theme_preference: true,
|
|
||||||
color_theme: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: "Preferences updated successfully",
|
|
||||||
preferences: {
|
|
||||||
theme_preference: updatedUser.theme_preference,
|
|
||||||
color_theme: updatedUser.color_theme,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating user preferences:", error);
|
|
||||||
res.status(500).json({ error: "Failed to update user preferences" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -70,7 +70,6 @@ const integrationRoutes = require("./routes/integrationRoutes");
|
|||||||
const wsRoutes = require("./routes/wsRoutes");
|
const wsRoutes = require("./routes/wsRoutes");
|
||||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
||||||
const metricsRoutes = require("./routes/metricsRoutes");
|
const metricsRoutes = require("./routes/metricsRoutes");
|
||||||
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
|
|
||||||
const { initSettings } = require("./services/settingsService");
|
const { initSettings } = require("./services/settingsService");
|
||||||
const { queueManager } = require("./services/automation");
|
const { queueManager } = require("./services/automation");
|
||||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
|
const { authenticateToken, requireAdmin } = require("./middleware/auth");
|
||||||
@@ -387,7 +386,6 @@ app.use(
|
|||||||
"Authorization",
|
"Authorization",
|
||||||
"Cookie",
|
"Cookie",
|
||||||
"X-Requested-With",
|
"X-Requested-With",
|
||||||
"X-Device-ID", // Allow device ID header for TFA remember-me functionality
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -479,7 +477,6 @@ app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
|
|||||||
app.use(`/api/${apiVersion}/ws`, wsRoutes);
|
app.use(`/api/${apiVersion}/ws`, wsRoutes);
|
||||||
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
|
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
|
||||||
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
||||||
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
|
|
||||||
|
|
||||||
// Bull Board - will be populated after queue manager initializes
|
// Bull Board - will be populated after queue manager initializes
|
||||||
let bullBoardRouter = null;
|
let bullBoardRouter = null;
|
||||||
@@ -559,6 +556,299 @@ app.use(`/bullboard`, (req, res, next) => {
|
|||||||
return res.status(503).json({ error: "Bull Board not initialized yet" });
|
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
|
// Error handler specifically for Bull Board routes
|
||||||
app.use("/bullboard", (err, req, res, _next) => {
|
app.use("/bullboard", (err, req, res, _next) => {
|
||||||
console.error("Bull Board error on", req.method, req.url);
|
console.error("Bull Board error on", req.method, req.url);
|
||||||
|
|||||||
@@ -176,15 +176,6 @@ function pushSettingsUpdate(apiId, newInterval) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushUpdateAgent(apiId) {
|
|
||||||
const ws = apiIdToSocket.get(apiId);
|
|
||||||
safeSend(ws, JSON.stringify({ type: "update_agent" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConnectionByApiId(apiId) {
|
|
||||||
return apiIdToSocket.get(apiId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushUpdateNotification(apiId, updateInfo) {
|
function pushUpdateNotification(apiId, updateInfo) {
|
||||||
const ws = apiIdToSocket.get(apiId);
|
const ws = apiIdToSocket.get(apiId);
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -339,12 +330,10 @@ module.exports = {
|
|||||||
broadcastSettingsUpdate,
|
broadcastSettingsUpdate,
|
||||||
pushReportNow,
|
pushReportNow,
|
||||||
pushSettingsUpdate,
|
pushSettingsUpdate,
|
||||||
pushUpdateAgent,
|
|
||||||
pushUpdateNotification,
|
pushUpdateNotification,
|
||||||
pushUpdateNotificationToAll,
|
pushUpdateNotificationToAll,
|
||||||
// Expose read-only view of connected agents
|
// Expose read-only view of connected agents
|
||||||
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
|
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
|
||||||
getConnectionByApiId,
|
|
||||||
isConnected: (apiId) => {
|
isConnected: (apiId) => {
|
||||||
const ws = apiIdToSocket.get(apiId);
|
const ws = apiIdToSocket.get(apiId);
|
||||||
return !!ws && ws.readyState === WebSocket.OPEN;
|
return !!ws && ws.readyState === WebSocket.OPEN;
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
const { prisma } = require("./shared/prisma");
|
|
||||||
const https = require("node:https");
|
|
||||||
const http = require("node:http");
|
|
||||||
const { v4: uuidv4 } = require("uuid");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Docker Image Update Check Automation
|
|
||||||
* Checks for Docker image updates by comparing local digests with remote registry digests
|
|
||||||
*/
|
|
||||||
class DockerImageUpdateCheck {
|
|
||||||
constructor(queueManager) {
|
|
||||||
this.queueManager = queueManager;
|
|
||||||
this.queueName = "docker-image-update-check";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get remote digest from Docker registry using HEAD request
|
|
||||||
* Supports Docker Hub, GHCR, and other OCI-compliant registries
|
|
||||||
*/
|
|
||||||
async getRemoteDigest(imageName, tag = "latest") {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Parse image name to determine registry
|
|
||||||
const registryInfo = this.parseImageName(imageName);
|
|
||||||
|
|
||||||
// Construct manifest URL
|
|
||||||
const manifestPath = `/v2/${registryInfo.repository}/manifests/${tag}`;
|
|
||||||
const options = {
|
|
||||||
hostname: registryInfo.registry,
|
|
||||||
path: manifestPath,
|
|
||||||
method: "HEAD",
|
|
||||||
headers: {
|
|
||||||
Accept:
|
|
||||||
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json",
|
|
||||||
"User-Agent": "PatchMon/1.0",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add authentication token for Docker Hub if needed
|
|
||||||
if (
|
|
||||||
registryInfo.registry === "registry-1.docker.io" &&
|
|
||||||
registryInfo.isPublic
|
|
||||||
) {
|
|
||||||
// For anonymous public images, we may need to get an auth token first
|
|
||||||
// For now, try without auth (works for public images)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Choose HTTP or HTTPS
|
|
||||||
const client = registryInfo.isSecure ? https : http;
|
|
||||||
|
|
||||||
const req = client.request(options, (res) => {
|
|
||||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
||||||
// Authentication required - skip for now (would need to implement auth)
|
|
||||||
return reject(
|
|
||||||
new Error(`Authentication required for ${imageName}:${tag}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
return reject(
|
|
||||||
new Error(
|
|
||||||
`Registry returned status ${res.statusCode} for ${imageName}:${tag}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get digest from Docker-Content-Digest header
|
|
||||||
const digest = res.headers["docker-content-digest"];
|
|
||||||
if (!digest) {
|
|
||||||
return reject(
|
|
||||||
new Error(
|
|
||||||
`No Docker-Content-Digest header for ${imageName}:${tag}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up digest (remove sha256: prefix if present)
|
|
||||||
const cleanDigest = digest.startsWith("sha256:")
|
|
||||||
? digest.substring(7)
|
|
||||||
: digest;
|
|
||||||
resolve(cleanDigest);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on("error", (error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.setTimeout(10000, () => {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error(`Timeout getting digest for ${imageName}:${tag}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse image name to extract registry, repository, and determine if secure
|
|
||||||
*/
|
|
||||||
parseImageName(imageName) {
|
|
||||||
let registry = "registry-1.docker.io";
|
|
||||||
let repository = imageName;
|
|
||||||
const isSecure = true;
|
|
||||||
let isPublic = true;
|
|
||||||
|
|
||||||
// Handle explicit registries (ghcr.io, quay.io, etc.)
|
|
||||||
if (imageName.includes("/")) {
|
|
||||||
const parts = imageName.split("/");
|
|
||||||
const firstPart = parts[0];
|
|
||||||
|
|
||||||
// Check for known registries
|
|
||||||
if (firstPart.includes(".") || firstPart === "localhost") {
|
|
||||||
registry = firstPart;
|
|
||||||
repository = parts.slice(1).join("/");
|
|
||||||
isPublic = false; // Assume private registries need auth for now
|
|
||||||
} else {
|
|
||||||
// Docker Hub - registry-1.docker.io
|
|
||||||
repository = imageName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker Hub official images (no namespace)
|
|
||||||
if (!repository.includes("/")) {
|
|
||||||
repository = `library/${repository}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
registry,
|
|
||||||
repository,
|
|
||||||
isSecure,
|
|
||||||
isPublic,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process Docker image update check job
|
|
||||||
*/
|
|
||||||
async process(_job) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
console.log("🐳 Starting Docker image update check...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get all Docker images that have a digest and repository
|
|
||||||
const images = await prisma.docker_images.findMany({
|
|
||||||
where: {
|
|
||||||
digest: {
|
|
||||||
not: null,
|
|
||||||
},
|
|
||||||
repository: {
|
|
||||||
not: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
docker_image_updates: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`📦 Found ${images.length} images to check for updates`);
|
|
||||||
|
|
||||||
let checkedCount = 0;
|
|
||||||
let updateCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
const errors = [];
|
|
||||||
|
|
||||||
// Process images in batches to avoid overwhelming the API
|
|
||||||
const batchSize = 10;
|
|
||||||
for (let i = 0; i < images.length; i += batchSize) {
|
|
||||||
const batch = images.slice(i, i + batchSize);
|
|
||||||
|
|
||||||
// Process batch concurrently with Promise.allSettled for error tolerance
|
|
||||||
const _results = await Promise.allSettled(
|
|
||||||
batch.map(async (image) => {
|
|
||||||
try {
|
|
||||||
checkedCount++;
|
|
||||||
|
|
||||||
// Skip local images (no digest means they're local)
|
|
||||||
if (!image.digest || image.digest.trim() === "") {
|
|
||||||
return { image, skipped: true, reason: "No digest" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get clean digest (remove sha256: prefix if present)
|
|
||||||
const localDigest = image.digest.startsWith("sha256:")
|
|
||||||
? image.digest.substring(7)
|
|
||||||
: image.digest;
|
|
||||||
|
|
||||||
// Get remote digest from registry
|
|
||||||
const remoteDigest = await this.getRemoteDigest(
|
|
||||||
image.repository,
|
|
||||||
image.tag || "latest",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Compare digests
|
|
||||||
if (localDigest !== remoteDigest) {
|
|
||||||
console.log(
|
|
||||||
`🔄 Update found: ${image.repository}:${image.tag} (local: ${localDigest.substring(0, 12)}..., remote: ${remoteDigest.substring(0, 12)}...)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store digest info in changelog_url field as JSON
|
|
||||||
const digestInfo = JSON.stringify({
|
|
||||||
method: "digest_comparison",
|
|
||||||
current_digest: localDigest,
|
|
||||||
available_digest: remoteDigest,
|
|
||||||
checked_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upsert the update record
|
|
||||||
await prisma.docker_image_updates.upsert({
|
|
||||||
where: {
|
|
||||||
image_id_available_tag: {
|
|
||||||
image_id: image.id,
|
|
||||||
available_tag: image.tag || "latest",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
updated_at: new Date(),
|
|
||||||
changelog_url: digestInfo,
|
|
||||||
severity: "digest_changed",
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: uuidv4(),
|
|
||||||
image_id: image.id,
|
|
||||||
current_tag: image.tag || "latest",
|
|
||||||
available_tag: image.tag || "latest",
|
|
||||||
severity: "digest_changed",
|
|
||||||
changelog_url: digestInfo,
|
|
||||||
updated_at: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update last_checked timestamp on image
|
|
||||||
await prisma.docker_images.update({
|
|
||||||
where: { id: image.id },
|
|
||||||
data: { last_checked: new Date() },
|
|
||||||
});
|
|
||||||
|
|
||||||
updateCount++;
|
|
||||||
return { image, updated: true };
|
|
||||||
} else {
|
|
||||||
// No update - still update last_checked
|
|
||||||
await prisma.docker_images.update({
|
|
||||||
where: { id: image.id },
|
|
||||||
data: { last_checked: new Date() },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove existing update record if digest matches now
|
|
||||||
const existingUpdate = image.docker_image_updates?.find(
|
|
||||||
(u) => u.available_tag === (image.tag || "latest"),
|
|
||||||
);
|
|
||||||
if (existingUpdate) {
|
|
||||||
await prisma.docker_image_updates.delete({
|
|
||||||
where: { id: existingUpdate.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { image, updated: false };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errorCount++;
|
|
||||||
const errorMsg = `Error checking ${image.repository}:${image.tag}: ${error.message}`;
|
|
||||||
errors.push(errorMsg);
|
|
||||||
console.error(`❌ ${errorMsg}`);
|
|
||||||
|
|
||||||
// Still update last_checked even on error
|
|
||||||
try {
|
|
||||||
await prisma.docker_images.update({
|
|
||||||
where: { id: image.id },
|
|
||||||
data: { last_checked: new Date() },
|
|
||||||
});
|
|
||||||
} catch (_updateError) {
|
|
||||||
// Ignore update errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return { image, error: error.message };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log batch progress
|
|
||||||
if (i + batchSize < images.length) {
|
|
||||||
console.log(
|
|
||||||
`⏳ Processed ${Math.min(i + batchSize, images.length)}/${images.length} images...`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay between batches to be respectful to registries
|
|
||||||
if (i + batchSize < images.length) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const executionTime = Date.now() - startTime;
|
|
||||||
console.log(
|
|
||||||
`✅ Docker image update check completed in ${executionTime}ms - Checked: ${checkedCount}, Updates: ${updateCount}, Errors: ${errorCount}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
checked: checkedCount,
|
|
||||||
updates: updateCount,
|
|
||||||
errors: errorCount,
|
|
||||||
executionTime,
|
|
||||||
errorDetails: errors,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const executionTime = Date.now() - startTime;
|
|
||||||
console.error(
|
|
||||||
`❌ Docker image update check failed after ${executionTime}ms:`,
|
|
||||||
error.message,
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule recurring Docker image update check (daily at 2 AM)
|
|
||||||
*/
|
|
||||||
async schedule() {
|
|
||||||
const job = await this.queueManager.queues[this.queueName].add(
|
|
||||||
"docker-image-update-check",
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
|
|
||||||
jobId: "docker-image-update-check-recurring",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.log("✅ Docker image update check scheduled");
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger manual Docker image update check
|
|
||||||
*/
|
|
||||||
async triggerManual() {
|
|
||||||
const job = await this.queueManager.queues[this.queueName].add(
|
|
||||||
"docker-image-update-check-manual",
|
|
||||||
{},
|
|
||||||
{ priority: 1 },
|
|
||||||
);
|
|
||||||
console.log("✅ Manual Docker image update check triggered");
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = DockerImageUpdateCheck;
|
|
||||||
@@ -2,7 +2,6 @@ const { Queue, Worker } = require("bullmq");
|
|||||||
const { redis, redisConnection } = require("./shared/redis");
|
const { redis, redisConnection } = require("./shared/redis");
|
||||||
const { prisma } = require("./shared/prisma");
|
const { prisma } = require("./shared/prisma");
|
||||||
const agentWs = require("../agentWs");
|
const agentWs = require("../agentWs");
|
||||||
const { v4: uuidv4 } = require("uuid");
|
|
||||||
|
|
||||||
// Import automation classes
|
// Import automation classes
|
||||||
const GitHubUpdateCheck = require("./githubUpdateCheck");
|
const GitHubUpdateCheck = require("./githubUpdateCheck");
|
||||||
@@ -10,7 +9,6 @@ const SessionCleanup = require("./sessionCleanup");
|
|||||||
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
|
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
|
||||||
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
|
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
|
||||||
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
|
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
|
||||||
const DockerImageUpdateCheck = require("./dockerImageUpdateCheck");
|
|
||||||
const MetricsReporting = require("./metricsReporting");
|
const MetricsReporting = require("./metricsReporting");
|
||||||
|
|
||||||
// Queue names
|
// Queue names
|
||||||
@@ -20,7 +18,6 @@ const QUEUE_NAMES = {
|
|||||||
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
|
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
|
||||||
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
|
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
|
||||||
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
|
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
|
||||||
DOCKER_IMAGE_UPDATE_CHECK: "docker-image-update-check",
|
|
||||||
METRICS_REPORTING: "metrics-reporting",
|
METRICS_REPORTING: "metrics-reporting",
|
||||||
AGENT_COMMANDS: "agent-commands",
|
AGENT_COMMANDS: "agent-commands",
|
||||||
};
|
};
|
||||||
@@ -100,8 +97,6 @@ class QueueManager {
|
|||||||
new OrphanedPackageCleanup(this);
|
new OrphanedPackageCleanup(this);
|
||||||
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
|
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
|
||||||
new DockerInventoryCleanup(this);
|
new DockerInventoryCleanup(this);
|
||||||
this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK] =
|
|
||||||
new DockerImageUpdateCheck(this);
|
|
||||||
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
|
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
|
||||||
this,
|
this,
|
||||||
);
|
);
|
||||||
@@ -172,15 +167,6 @@ class QueueManager {
|
|||||||
workerOptions,
|
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
|
// Metrics Reporting Worker
|
||||||
this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
|
this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
|
||||||
QUEUE_NAMES.METRICS_REPORTING,
|
QUEUE_NAMES.METRICS_REPORTING,
|
||||||
@@ -197,87 +183,15 @@ class QueueManager {
|
|||||||
const { api_id, type } = job.data;
|
const { api_id, type } = job.data;
|
||||||
console.log(`Processing agent command: ${type} for ${api_id}`);
|
console.log(`Processing agent command: ${type} for ${api_id}`);
|
||||||
|
|
||||||
// Log job to job_history
|
// Send command via WebSocket based on type
|
||||||
let historyRecord = null;
|
if (type === "report_now") {
|
||||||
try {
|
agentWs.pushReportNow(api_id);
|
||||||
const host = await prisma.hosts.findUnique({
|
} else if (type === "settings_update") {
|
||||||
where: { api_id },
|
// For settings update, we need additional data
|
||||||
select: { id: true },
|
const { update_interval } = job.data;
|
||||||
});
|
agentWs.pushSettingsUpdate(api_id, update_interval);
|
||||||
|
} else {
|
||||||
if (host) {
|
console.error(`Unknown agent command type: ${type}`);
|
||||||
historyRecord = await prisma.job_history.create({
|
|
||||||
data: {
|
|
||||||
id: uuidv4(),
|
|
||||||
job_id: job.id,
|
|
||||||
queue_name: QUEUE_NAMES.AGENT_COMMANDS,
|
|
||||||
job_name: type,
|
|
||||||
host_id: host.id,
|
|
||||||
api_id: api_id,
|
|
||||||
status: "active",
|
|
||||||
attempt_number: job.attemptsMade + 1,
|
|
||||||
created_at: new Date(),
|
|
||||||
updated_at: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`📝 Logged job to job_history: ${job.id} (${type})`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to log job to job_history:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Send command via WebSocket based on type
|
|
||||||
if (type === "report_now") {
|
|
||||||
agentWs.pushReportNow(api_id);
|
|
||||||
} else if (type === "settings_update") {
|
|
||||||
// For settings update, we need additional data
|
|
||||||
const { update_interval } = job.data;
|
|
||||||
agentWs.pushSettingsUpdate(api_id, update_interval);
|
|
||||||
} else if (type === "update_agent") {
|
|
||||||
// Force agent to update by sending WebSocket command
|
|
||||||
const ws = agentWs.getConnectionByApiId(api_id);
|
|
||||||
if (ws && ws.readyState === 1) {
|
|
||||||
// WebSocket.OPEN
|
|
||||||
agentWs.pushUpdateAgent(api_id);
|
|
||||||
console.log(`✅ Update command sent to agent ${api_id}`);
|
|
||||||
} else {
|
|
||||||
console.error(`❌ Agent ${api_id} is not connected`);
|
|
||||||
throw new Error(
|
|
||||||
`Agent ${api_id} is not connected. Cannot send update command.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(`Unknown agent command type: ${type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update job history to completed
|
|
||||||
if (historyRecord) {
|
|
||||||
await prisma.job_history.updateMany({
|
|
||||||
where: { job_id: job.id },
|
|
||||||
data: {
|
|
||||||
status: "completed",
|
|
||||||
completed_at: new Date(),
|
|
||||||
updated_at: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`✅ Marked job as completed in job_history: ${job.id}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Update job history to failed
|
|
||||||
if (historyRecord) {
|
|
||||||
await prisma.job_history.updateMany({
|
|
||||||
where: { job_id: job.id },
|
|
||||||
data: {
|
|
||||||
status: "failed",
|
|
||||||
error_message: error.message,
|
|
||||||
completed_at: new Date(),
|
|
||||||
updated_at: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`❌ Marked job as failed in job_history: ${job.id}`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
workerOptions,
|
workerOptions,
|
||||||
@@ -307,7 +221,6 @@ class QueueManager {
|
|||||||
console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`);
|
console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ Queue events initialized");
|
console.log("✅ Queue events initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +233,6 @@ class QueueManager {
|
|||||||
await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
|
await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
|
||||||
await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_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_INVENTORY_CLEANUP].schedule();
|
||||||
await this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].schedule();
|
|
||||||
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
|
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,12 +263,6 @@ class QueueManager {
|
|||||||
].triggerManual();
|
].triggerManual();
|
||||||
}
|
}
|
||||||
|
|
||||||
async triggerDockerImageUpdateCheck() {
|
|
||||||
return this.automations[
|
|
||||||
QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK
|
|
||||||
].triggerManual();
|
|
||||||
}
|
|
||||||
|
|
||||||
async triggerMetricsReporting() {
|
async triggerMetricsReporting() {
|
||||||
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
|
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* Docker-related utility functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a registry link for a Docker image based on its repository and source
|
|
||||||
* Inspired by diun's registry link generation
|
|
||||||
* @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo")
|
|
||||||
* @param {string} source - The detected source (github, gitlab, docker-hub, etc.)
|
|
||||||
* @returns {string|null} - The URL to the registry page, or null if unknown
|
|
||||||
*/
|
|
||||||
function generateRegistryLink(repository, source) {
|
|
||||||
if (!repository) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the domain and path from the repository
|
|
||||||
const parts = repository.split("/");
|
|
||||||
let domain = "";
|
|
||||||
let path = "";
|
|
||||||
|
|
||||||
// Check if repository has a domain (contains a dot)
|
|
||||||
if (parts[0].includes(".") || parts[0].includes(":")) {
|
|
||||||
domain = parts[0];
|
|
||||||
path = parts.slice(1).join("/");
|
|
||||||
} else {
|
|
||||||
// No domain means Docker Hub
|
|
||||||
domain = "docker.io";
|
|
||||||
path = repository;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (source) {
|
|
||||||
case "docker-hub":
|
|
||||||
case "docker.io": {
|
|
||||||
// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images
|
|
||||||
// Official images are those without a namespace (e.g., "postgres" not "user/postgres")
|
|
||||||
// or explicitly prefixed with "library/"
|
|
||||||
if (path.startsWith("library/")) {
|
|
||||||
const cleanPath = path.replace("library/", "");
|
|
||||||
return `https://hub.docker.com/_/${cleanPath}`;
|
|
||||||
}
|
|
||||||
// Check if it's an official image (single part, no slash after removing library/)
|
|
||||||
if (!path.includes("/")) {
|
|
||||||
return `https://hub.docker.com/_/${path}`;
|
|
||||||
}
|
|
||||||
// Regular user/org image
|
|
||||||
return `https://hub.docker.com/r/${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "github":
|
|
||||||
case "ghcr.io": {
|
|
||||||
// GitHub Container Registry
|
|
||||||
// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package}
|
|
||||||
// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package}
|
|
||||||
if (domain === "ghcr.io" && path) {
|
|
||||||
const pathParts = path.split("/");
|
|
||||||
if (pathParts.length === 2) {
|
|
||||||
// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package
|
|
||||||
// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package}
|
|
||||||
// Actually, for 2 parts it's owner/package, and repo is same as owner typically
|
|
||||||
const owner = pathParts[0];
|
|
||||||
const packageName = pathParts[1];
|
|
||||||
return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`;
|
|
||||||
} else if (pathParts.length >= 3) {
|
|
||||||
// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package
|
|
||||||
const owner = pathParts[0];
|
|
||||||
const repo = pathParts[1];
|
|
||||||
const packageName = pathParts.slice(2).join("/");
|
|
||||||
return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Legacy GitHub Packages
|
|
||||||
if (domain === "docker.pkg.github.com" && path) {
|
|
||||||
const pathParts = path.split("/");
|
|
||||||
if (pathParts.length >= 1) {
|
|
||||||
return `https://github.com/${pathParts[0]}/packages`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "gitlab":
|
|
||||||
case "registry.gitlab.com": {
|
|
||||||
// GitLab Container Registry: https://gitlab.com/{path}/container_registry
|
|
||||||
if (path) {
|
|
||||||
return `https://gitlab.com/${path}/container_registry`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "google":
|
|
||||||
case "gcr.io": {
|
|
||||||
// Google Container Registry: https://gcr.io/{path}
|
|
||||||
if (domain.includes("gcr.io") || domain.includes("pkg.dev")) {
|
|
||||||
return `https://console.cloud.google.com/gcr/images/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "quay":
|
|
||||||
case "quay.io": {
|
|
||||||
// Quay.io: https://quay.io/repository/{path}
|
|
||||||
if (path) {
|
|
||||||
return `https://quay.io/repository/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "redhat":
|
|
||||||
case "registry.access.redhat.com": {
|
|
||||||
// Red Hat: https://access.redhat.com/containers/#/registry.access.redhat.com/{path}
|
|
||||||
if (path) {
|
|
||||||
return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure":
|
|
||||||
case "azurecr.io": {
|
|
||||||
// Azure Container Registry - link to portal
|
|
||||||
// Format: {registry}.azurecr.io/{repository}
|
|
||||||
if (domain.includes("azurecr.io")) {
|
|
||||||
const registryName = domain.split(".")[0];
|
|
||||||
return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "aws":
|
|
||||||
case "amazonaws.com": {
|
|
||||||
// AWS ECR - link to console
|
|
||||||
// Format: {account}.dkr.ecr.{region}.amazonaws.com/{repository}
|
|
||||||
if (domain.includes("amazonaws.com")) {
|
|
||||||
const domainParts = domain.split(".");
|
|
||||||
const region = domainParts[3]; // Extract region
|
|
||||||
return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "private":
|
|
||||||
// For private registries, try to construct a basic URL
|
|
||||||
if (domain) {
|
|
||||||
return `https://${domain}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a user-friendly display name for a registry source
|
|
||||||
* @param {string} source - The source identifier
|
|
||||||
* @returns {string} - Human-readable source name
|
|
||||||
*/
|
|
||||||
function getSourceDisplayName(source) {
|
|
||||||
const sourceNames = {
|
|
||||||
"docker-hub": "Docker Hub",
|
|
||||||
github: "GitHub",
|
|
||||||
gitlab: "GitLab",
|
|
||||||
google: "Google",
|
|
||||||
quay: "Quay.io",
|
|
||||||
redhat: "Red Hat",
|
|
||||||
azure: "Azure",
|
|
||||||
aws: "AWS ECR",
|
|
||||||
private: "Private Registry",
|
|
||||||
local: "Local",
|
|
||||||
unknown: "Unknown",
|
|
||||||
};
|
|
||||||
|
|
||||||
return sourceNames[source] || source;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
generateRegistryLink,
|
|
||||||
getSourceDisplayName,
|
|
||||||
};
|
|
||||||
@@ -84,20 +84,21 @@ function parse_expiration(expiration_string) {
|
|||||||
* Generate device fingerprint from request data
|
* Generate device fingerprint from request data
|
||||||
*/
|
*/
|
||||||
function generate_device_fingerprint(req) {
|
function generate_device_fingerprint(req) {
|
||||||
// Use the X-Device-ID header from frontend (unique per browser profile/localStorage)
|
const components = [
|
||||||
const deviceId = req.get("x-device-id");
|
req.get("user-agent") || "",
|
||||||
|
req.get("accept-language") || "",
|
||||||
|
req.get("accept-encoding") || "",
|
||||||
|
req.ip || "",
|
||||||
|
];
|
||||||
|
|
||||||
if (deviceId) {
|
// Create a simple hash of device characteristics
|
||||||
// Hash the device ID for consistent storage format
|
const fingerprint = crypto
|
||||||
return crypto
|
.createHash("sha256")
|
||||||
.createHash("sha256")
|
.update(components.join("|"))
|
||||||
.update(deviceId)
|
.digest("hex")
|
||||||
.digest("hex")
|
.substring(0, 32); // Use first 32 chars for storage efficiency
|
||||||
.substring(0, 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No device ID - return null (user needs to provide device ID for remember-me)
|
return fingerprint;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ WORKDIR /app/backend
|
|||||||
|
|
||||||
RUN npm cache clean --force &&\
|
RUN npm cache clean --force &&\
|
||||||
rm -rf node_modules ~/.npm /root/.npm &&\
|
rm -rf node_modules ~/.npm /root/.npm &&\
|
||||||
npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
|
npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0 &&\
|
||||||
PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
|
PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
|
||||||
npm prune --omit=dev &&\
|
npm prune --omit=dev &&\
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|||||||
@@ -21,13 +21,9 @@ WORKDIR /app/frontend
|
|||||||
|
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
RUN echo "=== Starting npm install ===" &&\
|
RUN npm cache clean --force &&\
|
||||||
npm cache clean --force &&\
|
|
||||||
rm -rf node_modules ~/.npm /root/.npm &&\
|
rm -rf node_modules ~/.npm /root/.npm &&\
|
||||||
echo "=== npm install ===" &&\
|
npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0
|
||||||
npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
|
|
||||||
echo "=== npm install completed ===" &&\
|
|
||||||
npm cache clean --force
|
|
||||||
|
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
server {
|
server {
|
||||||
listen 3000;
|
listen 3000;
|
||||||
listen [::]:3000;
|
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon-frontend",
|
"name": "patchmon-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.2",
|
"version": "1.3.1",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,12 +27,13 @@
|
|||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.30.1"
|
"react-router-dom": "^6.30.1",
|
||||||
|
"trianglify": "^4.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.14",
|
"@types/react": "^18.3.14",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import SettingsLayout from "./components/SettingsLayout";
|
|||||||
import { isAuthPhase } from "./constants/authPhases";
|
import { isAuthPhase } from "./constants/authPhases";
|
||||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||||
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
|
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
|
||||||
import { SettingsProvider } from "./contexts/SettingsContext";
|
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
||||||
|
|
||||||
@@ -29,8 +28,6 @@ const DockerContainerDetail = lazy(
|
|||||||
);
|
);
|
||||||
const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail"));
|
const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail"));
|
||||||
const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail"));
|
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 AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
|
||||||
const Integrations = lazy(() => import("./pages/settings/Integrations"));
|
const Integrations = lazy(() => import("./pages/settings/Integrations"));
|
||||||
const Notifications = lazy(() => import("./pages/settings/Notifications"));
|
const Notifications = lazy(() => import("./pages/settings/Notifications"));
|
||||||
@@ -197,26 +194,6 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</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
|
<Route
|
||||||
path="/users"
|
path="/users"
|
||||||
element={
|
element={
|
||||||
@@ -450,19 +427,17 @@ function AppRoutes() {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<ThemeProvider>
|
||||||
<ThemeProvider>
|
<ColorThemeProvider>
|
||||||
<SettingsProvider>
|
<AuthProvider>
|
||||||
<ColorThemeProvider>
|
<UpdateNotificationProvider>
|
||||||
<UpdateNotificationProvider>
|
<LogoProvider>
|
||||||
<LogoProvider>
|
<AppRoutes />
|
||||||
<AppRoutes />
|
</LogoProvider>
|
||||||
</LogoProvider>
|
</UpdateNotificationProvider>
|
||||||
</UpdateNotificationProvider>
|
</AuthProvider>
|
||||||
</ColorThemeProvider>
|
</ColorThemeProvider>
|
||||||
</SettingsProvider>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { FaReddit, FaYoutube } from "react-icons/fa";
|
import { FaReddit, FaYoutube } from "react-icons/fa";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import trianglify from "trianglify";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useColorTheme } from "../contexts/ColorThemeContext";
|
import { useColorTheme } from "../contexts/ColorThemeContext";
|
||||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||||
@@ -236,93 +237,31 @@ const Layout = ({ children }) => {
|
|||||||
navigate("/hosts?action=add");
|
navigate("/hosts?action=add");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate clean radial gradient background with subtle triangular accents for dark mode
|
// Generate Trianglify background for dark mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generateBackground = () => {
|
const generateBackground = () => {
|
||||||
if (
|
if (
|
||||||
!bgCanvasRef.current ||
|
bgCanvasRef.current &&
|
||||||
!themeConfig?.login ||
|
themeConfig?.login &&
|
||||||
!document.documentElement.classList.contains("dark")
|
document.documentElement.classList.contains("dark")
|
||||||
) {
|
) {
|
||||||
return;
|
// Get current date as seed for daily variation
|
||||||
}
|
const today = new Date();
|
||||||
|
const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
||||||
|
|
||||||
const canvas = bgCanvasRef.current;
|
// Generate pattern with selected theme configuration
|
||||||
canvas.width = window.innerWidth;
|
const pattern = trianglify({
|
||||||
canvas.height = window.innerHeight;
|
width: window.innerWidth,
|
||||||
const ctx = canvas.getContext("2d");
|
height: window.innerHeight,
|
||||||
|
cellSize: themeConfig.login.cellSize,
|
||||||
|
variance: themeConfig.login.variance,
|
||||||
|
seed: dateSeed,
|
||||||
|
xColors: themeConfig.login.xColors,
|
||||||
|
yColors: themeConfig.login.yColors,
|
||||||
|
});
|
||||||
|
|
||||||
// Get theme colors - pick first color from each palette
|
// Render to canvas
|
||||||
const xColors = themeConfig.login.xColors || [
|
pattern.toCanvas(bgCanvasRef.current);
|
||||||
"#667eea",
|
|
||||||
"#764ba2",
|
|
||||||
"#f093fb",
|
|
||||||
"#4facfe",
|
|
||||||
];
|
|
||||||
const yColors = themeConfig.login.yColors || [
|
|
||||||
"#667eea",
|
|
||||||
"#764ba2",
|
|
||||||
"#f093fb",
|
|
||||||
"#4facfe",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use date for daily color rotation
|
|
||||||
const today = new Date();
|
|
||||||
const seed =
|
|
||||||
today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
|
|
||||||
const random = (s) => {
|
|
||||||
const x = Math.sin(s) * 10000;
|
|
||||||
return x - Math.floor(x);
|
|
||||||
};
|
|
||||||
|
|
||||||
const color1 = xColors[Math.floor(random(seed) * xColors.length)];
|
|
||||||
const color2 = yColors[Math.floor(random(seed + 1000) * yColors.length)];
|
|
||||||
|
|
||||||
// Create clean radial gradient from center to bottom-right corner
|
|
||||||
const gradient = ctx.createRadialGradient(
|
|
||||||
canvas.width * 0.3, // Center slightly left
|
|
||||||
canvas.height * 0.3, // Center slightly up
|
|
||||||
0,
|
|
||||||
canvas.width * 0.5, // Expand to cover screen
|
|
||||||
canvas.height * 0.5,
|
|
||||||
Math.max(canvas.width, canvas.height) * 1.2,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subtle gradient with darker corners
|
|
||||||
gradient.addColorStop(0, color1);
|
|
||||||
gradient.addColorStop(0.6, color2);
|
|
||||||
gradient.addColorStop(1, "#0a0a0a"); // Very dark edges
|
|
||||||
|
|
||||||
ctx.fillStyle = gradient;
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// Add subtle triangular shapes as accents across entire background
|
|
||||||
const cellSize = 180;
|
|
||||||
const cols = Math.ceil(canvas.width / cellSize) + 1;
|
|
||||||
const rows = Math.ceil(canvas.height / cellSize) + 1;
|
|
||||||
|
|
||||||
for (let y = 0; y < rows; y++) {
|
|
||||||
for (let x = 0; x < cols; x++) {
|
|
||||||
const idx = y * cols + x;
|
|
||||||
// Draw more triangles (less sparse)
|
|
||||||
if (random(seed + idx + 5000) > 0.4) {
|
|
||||||
const baseX =
|
|
||||||
x * cellSize + random(seed + idx * 3) * cellSize * 0.8;
|
|
||||||
const baseY =
|
|
||||||
y * cellSize + random(seed + idx * 3 + 100) * cellSize * 0.8;
|
|
||||||
const size = 50 + random(seed + idx * 4) * 100;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(baseX, baseY);
|
|
||||||
ctx.lineTo(baseX + size, baseY);
|
|
||||||
ctx.lineTo(baseX + size / 2, baseY - size * 0.866);
|
|
||||||
ctx.closePath();
|
|
||||||
|
|
||||||
// More visible white with slightly higher opacity
|
|
||||||
ctx.fillStyle = `rgba(255, 255, 255, ${0.05 + random(seed + idx * 5) * 0.08})`;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useSettings } from "../contexts/SettingsContext";
|
import { isAuthReady } from "../constants/authPhases";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { settingsAPI } from "../utils/api";
|
||||||
|
|
||||||
const LogoProvider = ({ children }) => {
|
const LogoProvider = ({ children }) => {
|
||||||
const { settings } = useSettings();
|
const { authPhase, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
|
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use custom favicon or fallback to default
|
// Use custom favicon or fallback to default
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Image,
|
||||||
|
Palette,
|
||||||
|
RotateCcw,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
|
||||||
import { settingsAPI } from "../../utils/api";
|
import { settingsAPI } from "../../utils/api";
|
||||||
|
|
||||||
const BrandingTab = () => {
|
const BrandingTab = () => {
|
||||||
@@ -12,6 +20,7 @@ const BrandingTab = () => {
|
|||||||
});
|
});
|
||||||
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
||||||
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
||||||
|
const { colorTheme, setColorTheme } = useColorTheme();
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -75,6 +84,22 @@ const BrandingTab = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Theme update mutation
|
||||||
|
const updateThemeMutation = useMutation({
|
||||||
|
mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
|
||||||
|
onSuccess: (_data, theme) => {
|
||||||
|
queryClient.invalidateQueries(["settings"]);
|
||||||
|
setColorTheme(theme);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Update theme error:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleThemeChange = (theme) => {
|
||||||
|
updateThemeMutation.mutate(theme);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -112,11 +137,93 @@ const BrandingTab = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||||
Customize your PatchMon installation with custom logos and favicon.
|
Customize your PatchMon installation with custom logos, favicon, and
|
||||||
These will be displayed throughout the application.
|
color themes. These will be displayed throughout the application.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Logo Section Header */}
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<Image className="h-5 w-5 text-primary-600 mr-2" />
|
<Image className="h-5 w-5 text-primary-600 mr-2" />
|
||||||
|
|||||||
@@ -91,29 +91,10 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
const login = async (username, password) => {
|
const login = async (username, password) => {
|
||||||
try {
|
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", {
|
const response = await fetch("/api/v1/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Device-ID": deviceId,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
@@ -138,9 +119,6 @@ export const AuthProvider = ({ children }) => {
|
|||||||
setPermissions(userPermissions);
|
setPermissions(userPermissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: User preferences will be automatically fetched by ColorThemeContext
|
|
||||||
// when the component mounts, so no need to invalidate here
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} else {
|
} else {
|
||||||
// Handle HTTP error responses (like 500 CORS errors)
|
// Handle HTTP error responses (like 500 CORS errors)
|
||||||
@@ -227,19 +205,8 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
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);
|
setUser(data.user);
|
||||||
localStorage.setItem("user", JSON.stringify(data.user));
|
localStorage.setItem("user", JSON.stringify(data.user));
|
||||||
|
|
||||||
return { success: true, user: data.user };
|
return { success: true, user: data.user };
|
||||||
} else {
|
} else {
|
||||||
// Handle HTTP error responses (like 500 CORS errors)
|
// Handle HTTP error responses (like 500 CORS errors)
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { userPreferencesAPI } from "../utils/api";
|
|
||||||
import { useAuth } from "./AuthContext";
|
|
||||||
|
|
||||||
const ColorThemeContext = createContext();
|
const ColorThemeContext = createContext();
|
||||||
|
|
||||||
@@ -132,108 +121,62 @@ export const THEME_PRESETS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ColorThemeProvider = ({ children }) => {
|
export const ColorThemeProvider = ({ children }) => {
|
||||||
const queryClient = useQueryClient();
|
const [colorTheme, setColorTheme] = useState("default");
|
||||||
const lastThemeRef = useRef(null);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
// Only update if the value actually changed from what we last saw (prevent loops)
|
const fetchTheme = async () => {
|
||||||
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 {
|
try {
|
||||||
await userPreferencesAPI.update({ color_theme: theme });
|
// Check localStorage first for unauthenticated pages (login)
|
||||||
|
const cachedTheme = localStorage.getItem("colorTheme");
|
||||||
|
if (cachedTheme) {
|
||||||
|
setColorTheme(cachedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidate and refetch user preferences to ensure sync across tabs/browsers
|
// Try to fetch from API (will fail on login page, that's ok)
|
||||||
await queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (token) {
|
||||||
|
const response = await fetch("/api/v1/settings", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.color_theme) {
|
||||||
|
setColorTheme(data.color_theme);
|
||||||
|
localStorage.setItem("colorTheme", data.color_theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_apiError) {
|
||||||
|
// Silent fail - use cached or default theme
|
||||||
|
console.log("Could not fetch theme from API, using cached/default");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save color theme preference:", error);
|
console.error("Error loading color theme:", error);
|
||||||
// Revert to previous theme if save failed
|
} finally {
|
||||||
setColorTheme(previousTheme);
|
setIsLoading(false);
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize themeConfig to prevent unnecessary re-renders
|
fetchTheme();
|
||||||
const themeConfig = useMemo(
|
}, []);
|
||||||
() => THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
|
|
||||||
[colorTheme],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize the context value to prevent unnecessary re-renders
|
const updateColorTheme = (theme) => {
|
||||||
const value = useMemo(
|
setColorTheme(theme);
|
||||||
() => ({
|
localStorage.setItem("colorTheme", theme);
|
||||||
colorTheme,
|
};
|
||||||
setColorTheme: updateColorTheme,
|
|
||||||
themeConfig,
|
const value = {
|
||||||
isLoading,
|
colorTheme,
|
||||||
}),
|
setColorTheme: updateColorTheme,
|
||||||
[colorTheme, themeConfig, isLoading, updateColorTheme],
|
themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
|
||||||
);
|
isLoading,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ColorThemeContext.Provider value={value}>
|
<ColorThemeContext.Provider value={value}>
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { createContext, useContext } from "react";
|
|
||||||
import { isAuthReady } from "../constants/authPhases";
|
|
||||||
import { settingsAPI } from "../utils/api";
|
|
||||||
import { useAuth } from "./AuthContext";
|
|
||||||
|
|
||||||
const SettingsContext = createContext();
|
|
||||||
|
|
||||||
export const useSettings = () => {
|
|
||||||
const context = useContext(SettingsContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSettings must be used within a SettingsProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SettingsProvider = ({ children }) => {
|
|
||||||
const { authPhase, isAuthenticated } = useAuth();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: settings,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["settings"],
|
|
||||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
|
||||||
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
|
||||||
});
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
settings,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</SettingsContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { userPreferencesAPI } from "../utils/api";
|
|
||||||
import { useAuth } from "./AuthContext";
|
|
||||||
|
|
||||||
const ThemeContext = createContext();
|
const ThemeContext = createContext();
|
||||||
|
|
||||||
@@ -15,7 +12,7 @@ export const useTheme = () => {
|
|||||||
|
|
||||||
export const ThemeProvider = ({ children }) => {
|
export const ThemeProvider = ({ children }) => {
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setTheme] = useState(() => {
|
||||||
// Check localStorage first for immediate render
|
// Check localStorage first, then system preference
|
||||||
const savedTheme = localStorage.getItem("theme");
|
const savedTheme = localStorage.getItem("theme");
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
return savedTheme;
|
return savedTheme;
|
||||||
@@ -27,30 +24,6 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
return "light";
|
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(() => {
|
useEffect(() => {
|
||||||
// Apply theme to document
|
// Apply theme to document
|
||||||
if (theme === "dark") {
|
if (theme === "dark") {
|
||||||
@@ -63,17 +36,8 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
localStorage.setItem("theme", theme);
|
localStorage.setItem("theme", theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const toggleTheme = async () => {
|
const toggleTheme = () => {
|
||||||
const newTheme = theme === "light" ? "dark" : "light";
|
setTheme((prevTheme) => (prevTheme === "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 = {
|
const value = {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { createContext, useContext, useState } from "react";
|
import { createContext, useContext, useState } from "react";
|
||||||
import { useSettings } from "./SettingsContext";
|
import { isAuthReady } from "../constants/authPhases";
|
||||||
|
import { settingsAPI } from "../utils/api";
|
||||||
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
const UpdateNotificationContext = createContext();
|
const UpdateNotificationContext = createContext();
|
||||||
|
|
||||||
@@ -15,7 +18,17 @@ export const useUpdateNotification = () => {
|
|||||||
|
|
||||||
export const UpdateNotificationProvider = ({ children }) => {
|
export const UpdateNotificationProvider = ({ children }) => {
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
const { settings, isLoading: settingsLoading } = useSettings();
|
const { authPhase, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
// Ensure settings are loaded - but only after auth is fully ready
|
||||||
|
// This reads cached update info from backend (updated by scheduler)
|
||||||
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||||
|
queryKey: ["settings"],
|
||||||
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
|
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||||
|
});
|
||||||
|
|
||||||
// Read cached update information from settings (no GitHub API calls)
|
// Read cached update information from settings (no GitHub API calls)
|
||||||
// The backend scheduler updates this data periodically
|
// The backend scheduler updates this data periodically
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Cpu,
|
Cpu,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
@@ -54,8 +53,6 @@ const HostDetail = () => {
|
|||||||
const [historyLimit] = useState(10);
|
const [historyLimit] = useState(10);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [notesMessage, setNotesMessage] = useState({ text: "", type: "" });
|
const [notesMessage, setNotesMessage] = useState({ text: "", type: "" });
|
||||||
const [updateMessage, setUpdateMessage] = useState({ text: "", jobId: "" });
|
|
||||||
const [reportMessage, setReportMessage] = useState({ text: "", jobId: "" });
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: host,
|
data: host,
|
||||||
@@ -190,57 +187,6 @@ const HostDetail = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force agent update mutation
|
|
||||||
const forceAgentUpdateMutation = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries(["host", hostId]);
|
|
||||||
queryClient.invalidateQueries(["hosts"]);
|
|
||||||
// Show success message with job ID
|
|
||||||
if (data?.jobId) {
|
|
||||||
setUpdateMessage({
|
|
||||||
text: "Update queued successfully",
|
|
||||||
jobId: data.jobId,
|
|
||||||
});
|
|
||||||
// Clear message after 5 seconds
|
|
||||||
setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setUpdateMessage({
|
|
||||||
text: error.response?.data?.error || "Failed to queue update",
|
|
||||||
jobId: "",
|
|
||||||
});
|
|
||||||
setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch report mutation
|
|
||||||
const fetchReportMutation = useMutation({
|
|
||||||
mutationFn: () => adminHostsAPI.fetchReport(hostId).then((res) => res.data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.invalidateQueries(["host", hostId]);
|
|
||||||
queryClient.invalidateQueries(["hosts"]);
|
|
||||||
// Show success message with job ID
|
|
||||||
if (data?.jobId) {
|
|
||||||
setReportMessage({
|
|
||||||
text: "Report fetch queued successfully",
|
|
||||||
jobId: data.jobId,
|
|
||||||
});
|
|
||||||
// Clear message after 5 seconds
|
|
||||||
setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setReportMessage({
|
|
||||||
text: error.response?.data?.error || "Failed to fetch report",
|
|
||||||
jobId: "",
|
|
||||||
});
|
|
||||||
setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateFriendlyNameMutation = useMutation({
|
const updateFriendlyNameMutation = useMutation({
|
||||||
mutationFn: (friendlyName) =>
|
mutationFn: (friendlyName) =>
|
||||||
adminHostsAPI
|
adminHostsAPI
|
||||||
@@ -453,53 +399,20 @@ const HostDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCredentialsModal(true)}
|
onClick={() => setShowCredentialsModal(true)}
|
||||||
className={`btn-outline flex items-center text-sm ${
|
className="btn-outline flex items-center gap-2 text-sm"
|
||||||
host?.machine_id ? "justify-center p-2" : "gap-2"
|
|
||||||
}`}
|
|
||||||
title="View credentials"
|
|
||||||
>
|
>
|
||||||
<Key className="h-4 w-4" />
|
<Key className="h-4 w-4" />
|
||||||
{!host?.machine_id && <span>Deploy Agent</span>}
|
Deploy Agent
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
className="btn-outline flex items-center justify-center p-2 text-sm"
|
className="btn-outline flex items-center justify-center p-2 text-sm"
|
||||||
title="Refresh dashboard"
|
title="Refresh host data"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||||
@@ -790,49 +703,6 @@ const HostDetail = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
|
|
||||||
Force Agent Version Upgrade
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => forceAgentUpdateMutation.mutate()}
|
|
||||||
disabled={
|
|
||||||
forceAgentUpdateMutation.isPending ||
|
|
||||||
!wsStatus?.connected
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
!wsStatus?.connected
|
|
||||||
? "Agent is not connected"
|
|
||||||
: "Force agent to update now"
|
|
||||||
}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-md hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-3 w-3 ${
|
|
||||||
forceAgentUpdateMutation.isPending
|
|
||||||
? "animate-spin"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{forceAgentUpdateMutation.isPending
|
|
||||||
? "Updating..."
|
|
||||||
: wsStatus?.connected
|
|
||||||
? "Update Now"
|
|
||||||
: "Offline"}
|
|
||||||
</button>
|
|
||||||
{updateMessage.text && (
|
|
||||||
<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400">
|
|
||||||
{updateMessage.text}
|
|
||||||
{updateMessage.jobId && (
|
|
||||||
<span className="ml-1 font-mono text-secondary-500">
|
|
||||||
(Job #{updateMessage.jobId})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -470,18 +470,9 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
|||||||
|
|
||||||
// Delete Confirmation Modal
|
// Delete Confirmation Modal
|
||||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
<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" />
|
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||||
@@ -503,30 +494,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
|||||||
</p>
|
</p>
|
||||||
{group._count.hosts > 0 && (
|
{group._count.hosts > 0 && (
|
||||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||||
<p className="text-sm text-warning-800 mb-2">
|
<p className="text-sm text-warning-800">
|
||||||
<strong>Warning:</strong> This group contains{" "}
|
<strong>Warning:</strong> This group contains{" "}
|
||||||
{group._count.hosts} host
|
{group._count.hosts} host
|
||||||
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
|
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
|
||||||
these hosts before deleting the group.
|
these hosts before deleting the group.
|
||||||
</p>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -531,11 +531,12 @@ const Hosts = () => {
|
|||||||
"with new data:",
|
"with new data:",
|
||||||
data.host,
|
data.host,
|
||||||
);
|
);
|
||||||
// Host already has host_group_memberships from backend
|
// Ensure hostGroupId is set correctly
|
||||||
const updatedHost = {
|
const updatedHost = {
|
||||||
...data.host,
|
...data.host,
|
||||||
|
hostGroupId: data.host.host_groups?.id || null,
|
||||||
};
|
};
|
||||||
console.log("Updated host in cache:", updatedHost);
|
console.log("Updated host with hostGroupId:", updatedHost);
|
||||||
return updatedHost;
|
return updatedHost;
|
||||||
}
|
}
|
||||||
return host;
|
return host;
|
||||||
@@ -653,15 +654,11 @@ const Hosts = () => {
|
|||||||
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
// Group filter - handle multiple groups per host
|
// Group filter
|
||||||
const memberships = host.host_group_memberships || [];
|
|
||||||
const matchesGroup =
|
const matchesGroup =
|
||||||
groupFilter === "all" ||
|
groupFilter === "all" ||
|
||||||
(groupFilter === "ungrouped" && memberships.length === 0) ||
|
(groupFilter === "ungrouped" && !host.host_groups) ||
|
||||||
(groupFilter !== "ungrouped" &&
|
(groupFilter !== "ungrouped" && host.host_groups?.id === groupFilter);
|
||||||
memberships.some(
|
|
||||||
(membership) => membership.host_groups?.id === groupFilter,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Status filter
|
// Status filter
|
||||||
const matchesStatus =
|
const matchesStatus =
|
||||||
@@ -714,30 +711,10 @@ const Hosts = () => {
|
|||||||
aValue = a.ip?.toLowerCase() || "zzz_no_ip";
|
aValue = a.ip?.toLowerCase() || "zzz_no_ip";
|
||||||
bValue = b.ip?.toLowerCase() || "zzz_no_ip";
|
bValue = b.ip?.toLowerCase() || "zzz_no_ip";
|
||||||
break;
|
break;
|
||||||
case "group": {
|
case "group":
|
||||||
// Handle multiple groups per host - use first group alphabetically for sorting
|
aValue = a.host_groups?.name || "zzz_ungrouped";
|
||||||
const aGroups = a.host_group_memberships || [];
|
bValue = b.host_groups?.name || "zzz_ungrouped";
|
||||||
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;
|
break;
|
||||||
}
|
|
||||||
case "os":
|
case "os":
|
||||||
aValue = a.os_type?.toLowerCase() || "zzz_unknown";
|
aValue = a.os_type?.toLowerCase() || "zzz_unknown";
|
||||||
bValue = b.os_type?.toLowerCase() || "zzz_unknown";
|
bValue = b.os_type?.toLowerCase() || "zzz_unknown";
|
||||||
@@ -810,46 +787,27 @@ const Hosts = () => {
|
|||||||
|
|
||||||
const groups = {};
|
const groups = {};
|
||||||
filteredAndSortedHosts.forEach((host) => {
|
filteredAndSortedHosts.forEach((host) => {
|
||||||
if (groupBy === "group") {
|
let groupKey;
|
||||||
// Handle multiple groups per host
|
switch (groupBy) {
|
||||||
const memberships = host.host_group_memberships || [];
|
case "group":
|
||||||
if (memberships.length === 0) {
|
groupKey = host.host_groups?.name || "Ungrouped";
|
||||||
// Host has no groups, add to "Ungrouped"
|
break;
|
||||||
if (!groups.Ungrouped) {
|
case "status":
|
||||||
groups.Ungrouped = [];
|
groupKey =
|
||||||
}
|
(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
|
||||||
groups.Ungrouped.push(host);
|
(host.effectiveStatus || host.status).slice(1);
|
||||||
} else {
|
break;
|
||||||
// Host has one or more groups, add to each group
|
case "os":
|
||||||
memberships.forEach((membership) => {
|
groupKey = host.os_type || "Unknown";
|
||||||
const groupName = membership.host_groups?.name || "Unknown";
|
break;
|
||||||
if (!groups[groupName]) {
|
default:
|
||||||
groups[groupName] = [];
|
groupKey = "All Hosts";
|
||||||
}
|
|
||||||
groups[groupName].push(host);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Other grouping types (status, os, etc.)
|
|
||||||
let groupKey;
|
|
||||||
switch (groupBy) {
|
|
||||||
case "status":
|
|
||||||
groupKey =
|
|
||||||
(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
|
|
||||||
(host.effectiveStatus || host.status).slice(1);
|
|
||||||
break;
|
|
||||||
case "os":
|
|
||||||
groupKey = host.os_type || "Unknown";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
groupKey = "All Hosts";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!groups[groupKey]) {
|
|
||||||
groups[groupKey] = [];
|
|
||||||
}
|
|
||||||
groups[groupKey].push(host);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!groups[groupKey]) {
|
||||||
|
groups[groupKey] = [];
|
||||||
|
}
|
||||||
|
groups[groupKey].push(host);
|
||||||
});
|
});
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
@@ -1436,6 +1394,14 @@ const Hosts = () => {
|
|||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
Hide Stale
|
Hide Stale
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useEffect, useId, useRef, useState } from "react";
|
|||||||
import { FaReddit, FaYoutube } from "react-icons/fa";
|
import { FaReddit, FaYoutube } from "react-icons/fa";
|
||||||
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import trianglify from "trianglify";
|
||||||
import DiscordIcon from "../components/DiscordIcon";
|
import DiscordIcon from "../components/DiscordIcon";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useColorTheme } from "../contexts/ColorThemeContext";
|
import { useColorTheme } from "../contexts/ColorThemeContext";
|
||||||
@@ -56,87 +57,27 @@ const Login = () => {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Generate clean radial gradient background with subtle triangular accents
|
// Generate Trianglify background based on selected theme
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generateBackground = () => {
|
const generateBackground = () => {
|
||||||
if (!canvasRef.current || !themeConfig?.login) return;
|
if (canvasRef.current && themeConfig?.login) {
|
||||||
|
// Get current date as seed for daily variation
|
||||||
|
const today = new Date();
|
||||||
|
const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
// Generate pattern with selected theme configuration
|
||||||
canvas.width = canvas.offsetWidth;
|
const pattern = trianglify({
|
||||||
canvas.height = canvas.offsetHeight;
|
width: canvasRef.current.offsetWidth,
|
||||||
const ctx = canvas.getContext("2d");
|
height: canvasRef.current.offsetHeight,
|
||||||
|
cellSize: themeConfig.login.cellSize,
|
||||||
|
variance: themeConfig.login.variance,
|
||||||
|
seed: dateSeed,
|
||||||
|
xColors: themeConfig.login.xColors,
|
||||||
|
yColors: themeConfig.login.yColors,
|
||||||
|
});
|
||||||
|
|
||||||
// Get theme colors - pick first color from each palette
|
// Render to canvas
|
||||||
const xColors = themeConfig.login.xColors || [
|
pattern.toCanvas(canvasRef.current);
|
||||||
"#667eea",
|
|
||||||
"#764ba2",
|
|
||||||
"#f093fb",
|
|
||||||
"#4facfe",
|
|
||||||
];
|
|
||||||
const yColors = themeConfig.login.yColors || [
|
|
||||||
"#667eea",
|
|
||||||
"#764ba2",
|
|
||||||
"#f093fb",
|
|
||||||
"#4facfe",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use date for daily color rotation
|
|
||||||
const today = new Date();
|
|
||||||
const seed =
|
|
||||||
today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
|
|
||||||
const random = (s) => {
|
|
||||||
const x = Math.sin(s) * 10000;
|
|
||||||
return x - Math.floor(x);
|
|
||||||
};
|
|
||||||
|
|
||||||
const color1 = xColors[Math.floor(random(seed) * xColors.length)];
|
|
||||||
const color2 = yColors[Math.floor(random(seed + 1000) * yColors.length)];
|
|
||||||
|
|
||||||
// Create clean radial gradient from center to bottom-right corner
|
|
||||||
const gradient = ctx.createRadialGradient(
|
|
||||||
canvas.width * 0.3, // Center slightly left
|
|
||||||
canvas.height * 0.3, // Center slightly up
|
|
||||||
0,
|
|
||||||
canvas.width * 0.5, // Expand to cover screen
|
|
||||||
canvas.height * 0.5,
|
|
||||||
Math.max(canvas.width, canvas.height) * 1.2,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subtle gradient with darker corners
|
|
||||||
gradient.addColorStop(0, color1);
|
|
||||||
gradient.addColorStop(0.6, color2);
|
|
||||||
gradient.addColorStop(1, "#0a0a0a"); // Very dark edges
|
|
||||||
|
|
||||||
ctx.fillStyle = gradient;
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// Add subtle triangular shapes as accents across entire background
|
|
||||||
const cellSize = 180;
|
|
||||||
const cols = Math.ceil(canvas.width / cellSize) + 1;
|
|
||||||
const rows = Math.ceil(canvas.height / cellSize) + 1;
|
|
||||||
|
|
||||||
for (let y = 0; y < rows; y++) {
|
|
||||||
for (let x = 0; x < cols; x++) {
|
|
||||||
const idx = y * cols + x;
|
|
||||||
// Draw more triangles (less sparse)
|
|
||||||
if (random(seed + idx + 5000) > 0.4) {
|
|
||||||
const baseX =
|
|
||||||
x * cellSize + random(seed + idx * 3) * cellSize * 0.8;
|
|
||||||
const baseY =
|
|
||||||
y * cellSize + random(seed + idx * 3 + 100) * cellSize * 0.8;
|
|
||||||
const size = 50 + random(seed + idx * 4) * 100;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(baseX, baseY);
|
|
||||||
ctx.lineTo(baseX + size, baseY);
|
|
||||||
ctx.lineTo(baseX + size / 2, baseY - size * 0.866);
|
|
||||||
ctx.closePath();
|
|
||||||
|
|
||||||
// More visible white with slightly higher opacity
|
|
||||||
ctx.fillStyle = `rgba(255, 255, 255, ${0.05 + random(seed + idx * 5) * 0.08})`;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,7 +90,7 @@ const Login = () => {
|
|||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
}, [themeConfig]);
|
}, [themeConfig]); // Regenerate when theme changes
|
||||||
|
|
||||||
// Check if signup is enabled
|
// Check if signup is enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -248,8 +189,7 @@ const Login = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch GitHub data:", error);
|
console.error("Failed to fetch GitHub data:", error);
|
||||||
// Set fallback data if nothing cached
|
// Set fallback data if nothing cached
|
||||||
const cachedRelease = localStorage.getItem("githubLatestRelease");
|
if (!latestRelease) {
|
||||||
if (!cachedRelease) {
|
|
||||||
setLatestRelease({
|
setLatestRelease({
|
||||||
version: "v1.3.0",
|
version: "v1.3.0",
|
||||||
name: "Latest Release",
|
name: "Latest Release",
|
||||||
@@ -261,7 +201,7 @@ const Login = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchGitHubData();
|
fetchGitHubData();
|
||||||
}, []); // Run once on mount
|
}, [latestRelease]);
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -408,12 +348,7 @@ const Login = () => {
|
|||||||
setTfaData({
|
setTfaData({
|
||||||
...tfaData,
|
...tfaData,
|
||||||
[name]:
|
[name]:
|
||||||
type === "checkbox"
|
type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6),
|
||||||
? checked
|
|
||||||
: value
|
|
||||||
.toUpperCase()
|
|
||||||
.replace(/[^A-Z0-9]/g, "")
|
|
||||||
.slice(0, 6),
|
|
||||||
});
|
});
|
||||||
// Clear error when user starts typing
|
// Clear error when user starts typing
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -878,8 +813,7 @@ const Login = () => {
|
|||||||
Two-Factor Authentication
|
Two-Factor Authentication
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400">
|
<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400">
|
||||||
Enter the code from your authenticator app, or use a backup
|
Enter the 6-digit code from your authenticator app
|
||||||
code
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -898,15 +832,11 @@ const Login = () => {
|
|||||||
required
|
required
|
||||||
value={tfaData.token}
|
value={tfaData.token}
|
||||||
onChange={handleTfaInputChange}
|
onChange={handleTfaInputChange}
|
||||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest uppercase"
|
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="Enter code"
|
placeholder="000000"
|
||||||
maxLength="6"
|
maxLength="6"
|
||||||
pattern="[A-Z0-9]{6}"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -966,6 +896,12 @@ const Login = () => {
|
|||||||
Back to Login
|
Back to Login
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -557,18 +557,9 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
|||||||
|
|
||||||
// Delete Confirmation Modal
|
// Delete Confirmation Modal
|
||||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
<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" />
|
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||||
@@ -590,30 +581,12 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
|||||||
</p>
|
</p>
|
||||||
{group._count.hosts > 0 && (
|
{group._count.hosts > 0 && (
|
||||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||||
<p className="text-sm text-warning-800 mb-2">
|
<p className="text-sm text-warning-800">
|
||||||
<strong>Warning:</strong> This group contains{" "}
|
<strong>Warning:</strong> This group contains{" "}
|
||||||
{group._count.hosts} host
|
{group._count.hosts} host
|
||||||
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
|
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
|
||||||
these hosts before deleting the group.
|
these hosts before deleting the group.
|
||||||
</p>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ const Packages = () => {
|
|||||||
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
<p className="text-sm text-secondary-500 dark:text-white">
|
||||||
Packages
|
Total Packages
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
{totalPackagesCount}
|
{totalPackagesCount}
|
||||||
@@ -553,7 +553,7 @@ const Packages = () => {
|
|||||||
<Package className="h-5 w-5 text-blue-600 mr-2" />
|
<Package className="h-5 w-5 text-blue-600 mr-2" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
<p className="text-sm text-secondary-500 dark:text-white">
|
||||||
Installations
|
Total Installations
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
{totalInstallationsCount}
|
{totalInstallationsCount}
|
||||||
@@ -562,72 +562,47 @@ const Packages = () => {
|
|||||||
</div>
|
</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">
|
||||||
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">
|
<div className="flex items-center">
|
||||||
<Package className="h-5 w-5 text-warning-600 mr-2" />
|
<Package className="h-5 w-5 text-warning-600 mr-2" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
<p className="text-sm text-secondary-500 dark:text-white">
|
||||||
Outdated Packages
|
Total Outdated Packages
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
{outdatedPackagesCount}
|
{outdatedPackagesCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setUpdateStatusFilter("security-updates");
|
|
||||||
setCategoryFilter("all");
|
|
||||||
setHostFilter("all");
|
|
||||||
setSearchTerm("");
|
|
||||||
}}
|
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
|
|
||||||
title="Click to filter packages with security updates"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Shield className="h-5 w-5 text-danger-600 mr-2" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
|
||||||
Security Packages
|
|
||||||
</p>
|
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
||||||
{securityUpdatesCount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate("/hosts?filter=needsUpdates")}
|
|
||||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
|
|
||||||
title="Click to view hosts that need updates"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Server className="h-5 w-5 text-warning-600 mr-2" />
|
<Server className="h-5 w-5 text-warning-600 mr-2" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
<p className="text-sm text-secondary-500 dark:text-white">
|
||||||
Outdated Hosts
|
Hosts Pending Updates
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
{uniquePackageHostsCount}
|
{uniquePackageHostsCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Shield className="h-5 w-5 text-danger-600 mr-2" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-secondary-500 dark:text-white">
|
||||||
|
Security Updates Across All Hosts
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||||
|
{securityUpdatesCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Packages List */}
|
{/* Packages List */}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
import { useEffect, useId, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { THEME_PRESETS, useColorTheme } from "../contexts/ColorThemeContext";
|
|
||||||
import { useTheme } from "../contexts/ThemeContext";
|
import { useTheme } from "../contexts/ThemeContext";
|
||||||
import { isCorsError, tfaAPI } from "../utils/api";
|
import { isCorsError, tfaAPI } from "../utils/api";
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ const Profile = () => {
|
|||||||
const confirmPasswordId = useId();
|
const confirmPasswordId = useId();
|
||||||
const { user, updateProfile, changePassword } = useAuth();
|
const { user, updateProfile, changePassword } = useAuth();
|
||||||
const { toggleTheme, isDark } = useTheme();
|
const { toggleTheme, isDark } = useTheme();
|
||||||
const { colorTheme, setColorTheme } = useColorTheme();
|
|
||||||
const [activeTab, setActiveTab] = useState("profile");
|
const [activeTab, setActiveTab] = useState("profile");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [message, setMessage] = useState({ type: "", text: "" });
|
const [message, setMessage] = useState({ type: "", text: "" });
|
||||||
@@ -80,10 +78,8 @@ const Profile = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setMessage({ type: "", text: "" });
|
setMessage({ type: "", text: "" });
|
||||||
|
|
||||||
console.log("Submitting profile data:", profileData);
|
|
||||||
try {
|
try {
|
||||||
const result = await updateProfile(profileData);
|
const result = await updateProfile(profileData);
|
||||||
console.log("Profile update result:", result);
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setMessage({ type: "success", text: "Profile updated successfully!" });
|
setMessage({ type: "success", text: "Profile updated successfully!" });
|
||||||
} else {
|
} else {
|
||||||
@@ -415,68 +411,6 @@ const Profile = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -630,7 +564,6 @@ const Profile = () => {
|
|||||||
// TFA Tab Component
|
// TFA Tab Component
|
||||||
const TfaTab = () => {
|
const TfaTab = () => {
|
||||||
const verificationTokenId = useId();
|
const verificationTokenId = useId();
|
||||||
const disablePasswordId = useId();
|
|
||||||
const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
|
const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
|
||||||
const [verificationToken, setVerificationToken] = useState("");
|
const [verificationToken, setVerificationToken] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|||||||
@@ -1,483 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
ArrowLeft,
|
|
||||||
CheckCircle,
|
|
||||||
Container,
|
|
||||||
Globe,
|
|
||||||
Network,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Tag,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
|
||||||
import api, { formatRelativeTime } from "../../utils/api";
|
|
||||||
|
|
||||||
const NetworkDetail = () => {
|
|
||||||
const { id } = useParams();
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: ["docker", "network", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await api.get(`/docker/networks/${id}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const network = data?.network;
|
|
||||||
const host = data?.host;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !network) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
|
||||||
Network not found
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
|
|
||||||
The network you're looking for doesn't exist or has been
|
|
||||||
removed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/docker"
|
|
||||||
className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
Back to Docker
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const BooleanBadge = ({ value, trueLabel = "Yes", falseLabel = "No" }) => {
|
|
||||||
return value ? (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
|
||||||
{trueLabel}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
|
||||||
<XCircle className="h-3 w-3 mr-1" />
|
|
||||||
{falseLabel}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
to="/docker"
|
|
||||||
className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
Back to Docker
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Network className="h-8 w-8 text-secondary-400 mr-3" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
|
||||||
{network.name}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
|
||||||
Network ID: {network.network_id.substring(0, 12)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overview Cards */}
|
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Network className="h-5 w-5 text-blue-600 mr-2" />
|
|
||||||
</div>
|
|
||||||
<div className="w-0 flex-1">
|
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
|
||||||
Driver
|
|
||||||
</p>
|
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
||||||
{network.driver}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Globe className="h-5 w-5 text-purple-600 mr-2" />
|
|
||||||
</div>
|
|
||||||
<div className="w-0 flex-1">
|
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
|
||||||
Scope
|
|
||||||
</p>
|
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
||||||
{network.scope}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Container className="h-5 w-5 text-green-600 mr-2" />
|
|
||||||
</div>
|
|
||||||
<div className="w-0 flex-1">
|
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
|
||||||
Containers
|
|
||||||
</p>
|
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
||||||
{network.container_count || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
|
|
||||||
</div>
|
|
||||||
<div className="w-0 flex-1">
|
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
|
||||||
Last Checked
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">
|
|
||||||
{formatRelativeTime(network.last_checked)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Network Information Card */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
|
|
||||||
Network Information
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Network ID
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
|
|
||||||
{network.network_id}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Name
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{network.name}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Driver
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
|
||||||
{network.driver}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Scope
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
|
||||||
{network.scope}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Containers Attached
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{network.container_count || 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
IPv6 Enabled
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<BooleanBadge value={network.ipv6_enabled} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Internal
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<BooleanBadge value={network.internal} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Attachable
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<BooleanBadge value={network.attachable} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Ingress
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<BooleanBadge value={network.ingress} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Config Only
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<BooleanBadge value={network.config_only} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{network.created_at && (
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Created
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{formatRelativeTime(network.created_at)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Last Checked
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{formatRelativeTime(network.last_checked)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* IPAM Configuration */}
|
|
||||||
{network.ipam && (
|
|
||||||
<div className="card">
|
|
||||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
|
|
||||||
IPAM Configuration
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
IP Address Management settings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
{network.ipam.driver && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-1">
|
|
||||||
Driver
|
|
||||||
</dt>
|
|
||||||
<dd>
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
|
||||||
{network.ipam.driver}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{network.ipam.config && network.ipam.config.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-3">
|
|
||||||
Subnet Configuration
|
|
||||||
</dt>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{network.ipam.config.map((config, index) => (
|
|
||||||
<div
|
|
||||||
key={config.subnet || `config-${index}`}
|
|
||||||
className="bg-secondary-50 dark:bg-secondary-900/50 rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
|
||||||
{config.subnet && (
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Subnet
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
|
|
||||||
{config.subnet}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{config.gateway && (
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Gateway
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
|
|
||||||
{config.gateway}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{config.ip_range && (
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
IP Range
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
|
|
||||||
{config.ip_range}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{config.aux_addresses &&
|
|
||||||
Object.keys(config.aux_addresses).length > 0 && (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
|
||||||
Auxiliary Addresses
|
|
||||||
</dt>
|
|
||||||
<dd className="space-y-1">
|
|
||||||
{Object.entries(config.aux_addresses).map(
|
|
||||||
([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-center text-sm"
|
|
||||||
>
|
|
||||||
<span className="text-secondary-500 dark:text-secondary-400 min-w-[120px]">
|
|
||||||
{key}:
|
|
||||||
</span>
|
|
||||||
<span className="text-secondary-900 dark:text-white font-mono">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{network.ipam.options &&
|
|
||||||
Object.keys(network.ipam.options).length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
|
||||||
IPAM Options
|
|
||||||
</dt>
|
|
||||||
<dd className="space-y-1">
|
|
||||||
{Object.entries(network.ipam.options).map(
|
|
||||||
([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
|
|
||||||
{key}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-secondary-900 dark:text-white break-all">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Host Information */}
|
|
||||||
{host && (
|
|
||||||
<div className="card">
|
|
||||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
|
|
||||||
<Server className="h-5 w-5 mr-2" />
|
|
||||||
Host Information
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Hostname
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
<Link
|
|
||||||
to={`/hosts/${host.id}`}
|
|
||||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
|
||||||
>
|
|
||||||
{host.hostname}
|
|
||||||
</Link>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Operating System
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{host.os_name} {host.os_version}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
{network.labels && Object.keys(network.labels).length > 0 && (
|
|
||||||
<div className="card">
|
|
||||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
|
|
||||||
<Tag className="h-5 w-5 mr-2" />
|
|
||||||
Labels
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(network.labels).map(([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
|
|
||||||
{key}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-secondary-900 dark:text-white break-all">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NetworkDetail;
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
ArrowLeft,
|
|
||||||
Database,
|
|
||||||
HardDrive,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Tag,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Link, useParams } from "react-router-dom";
|
|
||||||
import api, { formatRelativeTime } from "../../utils/api";
|
|
||||||
|
|
||||||
const VolumeDetail = () => {
|
|
||||||
const { id } = useParams();
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: ["docker", "volume", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await api.get(`/docker/volumes/${id}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const volume = data?.volume;
|
|
||||||
const host = data?.host;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !volume) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
|
||||||
Volume not found
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
|
|
||||||
The volume you're looking for doesn't exist or has been removed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/docker"
|
|
||||||
className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
Back to Docker
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatBytes = (bytes) => {
|
|
||||||
if (bytes === null || bytes === undefined) return "N/A";
|
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
|
||||||
if (bytes === 0) return "0 Bytes";
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
to="/docker"
|
|
||||||
className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
Back to Docker
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<HardDrive className="h-8 w-8 text-secondary-400 mr-3" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
|
||||||
{volume.name}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
|
||||||
Volume ID: {volume.volume_id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overview Cards */}
|
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<HardDrive className="h-5 w-5 text-blue-600 mr-2" />
|
|
||||||
</div>
|
|
||||||
<div className="w-0 flex-1">
|
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
|
||||||
Driver
|
|
||||||
</p>
|
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
||||||
{volume.driver}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Database className="h-5 w-5 text-purple-600 mr-2" />
|
|
||||||
</div>
|
|
||||||
<div className="w-0 flex-1">
|
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">Size</p>
|
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
||||||
{formatBytes(volume.size_bytes)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Server className="h-5 w-5 text-green-600 mr-2" />
|
|
||||||
</div>
|
|
||||||
<div className="w-0 flex-1">
|
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
|
||||||
Containers
|
|
||||||
</p>
|
|
||||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
|
||||||
{volume.ref_count || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
|
|
||||||
</div>
|
|
||||||
<div className="w-0 flex-1">
|
|
||||||
<p className="text-sm text-secondary-500 dark:text-white">
|
|
||||||
Last Checked
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">
|
|
||||||
{formatRelativeTime(volume.last_checked)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Volume Information Card */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
|
|
||||||
Volume Information
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Volume ID
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
|
|
||||||
{volume.volume_id}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Name
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{volume.name}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Driver
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
|
||||||
{volume.driver}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Scope
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
|
||||||
{volume.scope}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Size
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{formatBytes(volume.size_bytes)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Containers Using
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{volume.ref_count || 0}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{volume.mountpoint && (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Mount Point
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
|
|
||||||
{volume.mountpoint}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{volume.renderer && (
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Renderer
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{volume.renderer}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Created
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{formatRelativeTime(volume.created_at)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Last Checked
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{formatRelativeTime(volume.last_checked)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Host Information */}
|
|
||||||
{host && (
|
|
||||||
<div className="card">
|
|
||||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
|
|
||||||
<Server className="h-5 w-5 mr-2" />
|
|
||||||
Host Information
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Hostname
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
<Link
|
|
||||||
to={`/hosts/${host.id}`}
|
|
||||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
|
||||||
>
|
|
||||||
{host.hostname}
|
|
||||||
</Link>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
Operating System
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
|
|
||||||
{host.os_name} {host.os_version}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
{volume.labels && Object.keys(volume.labels).length > 0 && (
|
|
||||||
<div className="card">
|
|
||||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
|
|
||||||
<Tag className="h-5 w-5 mr-2" />
|
|
||||||
Labels
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(volume.labels).map(([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
|
|
||||||
{key}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-secondary-900 dark:text-white break-all">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Options */}
|
|
||||||
{volume.options && Object.keys(volume.options).length > 0 && (
|
|
||||||
<div className="card">
|
|
||||||
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
|
|
||||||
Volume Options
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{Object.entries(volume.options).map(([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
|
|
||||||
{key}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-secondary-900 dark:text-white break-all">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VolumeDetail;
|
|
||||||
@@ -746,126 +746,239 @@ const Integrations = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||||
Docker Inventory Collection
|
Docker Container Monitoring
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||||
Docker monitoring is now built into the PatchMon Go agent
|
Monitor Docker containers and images for available updates
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Message */}
|
{/* Installation Instructions */}
|
||||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
|
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
|
||||||
<div className="flex items-start gap-3">
|
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-4">
|
||||||
<CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
|
Agent Installation
|
||||||
<div>
|
</h3>
|
||||||
<h4 className="text-md font-semibold text-primary-900 dark:text-primary-200 mb-2">
|
<ol className="list-decimal list-inside space-y-3 text-sm text-primary-800 dark:text-primary-300">
|
||||||
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>
|
<li>
|
||||||
Install the PatchMon Go agent on your host (see the Hosts
|
Make sure you have the PatchMon credentials file set up on
|
||||||
page for installation instructions)
|
your host (
|
||||||
|
<code className="bg-primary-100 dark:bg-primary-900/40 px-1 py-0.5 rounded text-xs">
|
||||||
|
/etc/patchmon/credentials
|
||||||
|
</code>
|
||||||
|
)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The agent automatically detects if Docker is installed and
|
SSH into your Docker host where you want to monitor
|
||||||
running on the host
|
containers
|
||||||
</li>
|
</li>
|
||||||
|
<li>Run the installation command below</li>
|
||||||
<li>
|
<li>
|
||||||
During each collection cycle, the agent gathers Docker
|
The agent will automatically collect Docker container and
|
||||||
inventory data and sends it to the PatchMon server
|
image information every 5 minutes
|
||||||
</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>
|
||||||
|
<li>View your Docker inventory in the Docker page</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No Configuration Required */}
|
{/* Installation Command */}
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||||
<div className="flex items-start gap-2">
|
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
|
||||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
Quick Installation (One-Line Command)
|
||||||
<div className="text-sm text-green-800 dark:text-green-200">
|
</h4>
|
||||||
<p className="font-semibold mb-1">
|
<div className="space-y-3">
|
||||||
No Additional Configuration Required
|
<div>
|
||||||
</p>
|
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
<p>
|
Download and install the Docker agent:
|
||||||
Once the Go agent is installed and Docker is running on
|
</div>
|
||||||
your host, Docker inventory collection happens
|
<div className="flex items-center gap-2">
|
||||||
automatically. No separate Docker agent or cron jobs
|
<input
|
||||||
needed.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Requirements */}
|
{/* Manual Installation Steps */}
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||||
|
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
|
||||||
|
Manual Installation Steps
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
<strong>Step 1:</strong> Download the agent
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
copy_to_clipboard(
|
||||||
|
`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`,
|
||||||
|
"docker-download",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="btn-primary p-2"
|
||||||
|
>
|
||||||
|
{copy_success["docker-download"] ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
<strong>Step 2:</strong> Make it executable
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value="chmod +x /usr/local/bin/patchmon-docker-agent.sh"
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
copy_to_clipboard(
|
||||||
|
"chmod +x /usr/local/bin/patchmon-docker-agent.sh",
|
||||||
|
"docker-chmod",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="btn-primary p-2"
|
||||||
|
>
|
||||||
|
{copy_success["docker-chmod"] ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
<strong>Step 3:</strong> Test the agent
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value="/usr/local/bin/patchmon-docker-agent.sh collect"
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
copy_to_clipboard(
|
||||||
|
"/usr/local/bin/patchmon-docker-agent.sh collect",
|
||||||
|
"docker-test",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="btn-primary p-2"
|
||||||
|
>
|
||||||
|
{copy_success["docker-test"] ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
|
<strong>Step 4:</strong> Set up automatic collection
|
||||||
|
(every 5 minutes)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value='echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -'
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
copy_to_clipboard(
|
||||||
|
'echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -',
|
||||||
|
"docker-cron",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="btn-primary p-2"
|
||||||
|
>
|
||||||
|
{copy_success["docker-cron"] ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prerequisites */}
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||||
<div className="flex items-start gap-2">
|
<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" />
|
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
<p className="font-semibold mb-2">Requirements:</p>
|
<p className="font-semibold mb-2">Prerequisites:</p>
|
||||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
<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>
|
<li>
|
||||||
Agent must have access to the Docker socket (
|
Docker must be installed and running on the host
|
||||||
<code className="bg-blue-100 dark:bg-blue-900/40 px-1 py-0.5 rounded text-xs">
|
</li>
|
||||||
/var/run/docker.sock
|
<li>
|
||||||
|
PatchMon credentials file must exist at{" "}
|
||||||
|
<code className="bg-yellow-100 dark:bg-yellow-900/40 px-1 py-0.5 rounded text-xs">
|
||||||
|
/etc/patchmon/credentials
|
||||||
</code>
|
</code>
|
||||||
)
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Typically requires running the agent as root or with
|
The host must have network access to your PatchMon
|
||||||
Docker group permissions
|
server
|
||||||
</li>
|
</li>
|
||||||
|
<li>The agent must run as root (or with sudo)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -215,8 +215,8 @@ const SettingsHostGroups = () => {
|
|||||||
title={`View hosts in ${group.name}`}
|
title={`View hosts in ${group.name}`}
|
||||||
>
|
>
|
||||||
<Server className="h-4 w-4 mr-2" />
|
<Server className="h-4 w-4 mr-2" />
|
||||||
{group._count?.hosts || 0} host
|
{group._count.hosts} host
|
||||||
{group._count?.hosts !== 1 ? "s" : ""}
|
{group._count.hosts !== 1 ? "s" : ""}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
@@ -539,18 +539,9 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
|||||||
|
|
||||||
// Delete Confirmation Modal
|
// Delete Confirmation Modal
|
||||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
<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" />
|
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||||
@@ -570,32 +561,14 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
|||||||
Are you sure you want to delete the host group{" "}
|
Are you sure you want to delete the host group{" "}
|
||||||
<span className="font-semibold">"{group.name}"</span>?
|
<span className="font-semibold">"{group.name}"</span>?
|
||||||
</p>
|
</p>
|
||||||
{group._count?.hosts > 0 && (
|
{group._count.hosts > 0 && (
|
||||||
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
<p className="text-sm text-blue-800 mb-2">
|
<p className="text-sm text-blue-800">
|
||||||
<strong>Note:</strong> This group contains {group._count?.hosts}{" "}
|
<strong>Note:</strong> This group contains {group._count.hosts}{" "}
|
||||||
host
|
host
|
||||||
{group._count?.hosts !== 1 ? "s" : ""}. These hosts will be
|
{group._count.hosts !== 1 ? "s" : ""}. These hosts will be moved
|
||||||
moved to "No group" after deletion.
|
to "No group" after deletion.
|
||||||
</p>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
BookOpen,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -179,19 +178,6 @@ const SettingsMetrics = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Metrics Toggle */}
|
{/* Metrics Toggle */}
|
||||||
|
|||||||
@@ -19,30 +19,6 @@ api.interceptors.request.use(
|
|||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${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;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -119,8 +95,6 @@ export const adminHostsAPI = {
|
|||||||
api.put("/hosts/bulk/groups", { hostIds, groupIds }),
|
api.put("/hosts/bulk/groups", { hostIds, groupIds }),
|
||||||
toggleAutoUpdate: (hostId, autoUpdate) =>
|
toggleAutoUpdate: (hostId, autoUpdate) =>
|
||||||
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: 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) =>
|
updateFriendlyName: (hostId, friendlyName) =>
|
||||||
api.patch(`/hosts/${hostId}/friendly-name`, {
|
api.patch(`/hosts/${hostId}/friendly-name`, {
|
||||||
friendly_name: friendlyName,
|
friendly_name: friendlyName,
|
||||||
@@ -169,12 +143,6 @@ export const settingsAPI = {
|
|||||||
getServerUrl: () => api.get("/settings/server-url"),
|
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
|
// Agent File Management API
|
||||||
export const agentFileAPI = {
|
export const agentFileAPI = {
|
||||||
getInfo: () => api.get("/hosts/agent/info"),
|
getInfo: () => api.get("/hosts/agent/info"),
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
/**
|
|
||||||
* Docker-related utility functions for the frontend
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a registry link for a Docker image based on its repository and source
|
|
||||||
* @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo")
|
|
||||||
* @param {string} source - The detected source (github, gitlab, docker-hub, etc.)
|
|
||||||
* @returns {string|null} - The URL to the registry page, or null if unknown
|
|
||||||
*/
|
|
||||||
export function generateRegistryLink(repository, source) {
|
|
||||||
if (!repository) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the domain and path from the repository
|
|
||||||
const parts = repository.split("/");
|
|
||||||
let domain = "";
|
|
||||||
let path = "";
|
|
||||||
|
|
||||||
// Check if repository has a domain (contains a dot)
|
|
||||||
if (parts[0].includes(".") || parts[0].includes(":")) {
|
|
||||||
domain = parts[0];
|
|
||||||
path = parts.slice(1).join("/");
|
|
||||||
} else {
|
|
||||||
// No domain means Docker Hub
|
|
||||||
domain = "docker.io";
|
|
||||||
path = repository;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (source) {
|
|
||||||
case "docker-hub":
|
|
||||||
case "docker.io": {
|
|
||||||
// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images
|
|
||||||
// Official images are those without a namespace (e.g., "postgres" not "user/postgres")
|
|
||||||
// or explicitly prefixed with "library/"
|
|
||||||
if (path.startsWith("library/")) {
|
|
||||||
const cleanPath = path.replace("library/", "");
|
|
||||||
return `https://hub.docker.com/_/${cleanPath}`;
|
|
||||||
}
|
|
||||||
// Check if it's an official image (single part, no slash after removing library/)
|
|
||||||
if (!path.includes("/")) {
|
|
||||||
return `https://hub.docker.com/_/${path}`;
|
|
||||||
}
|
|
||||||
// Regular user/org image
|
|
||||||
return `https://hub.docker.com/r/${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "github":
|
|
||||||
case "ghcr.io": {
|
|
||||||
// GitHub Container Registry
|
|
||||||
// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package}
|
|
||||||
// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package}
|
|
||||||
if (domain === "ghcr.io" && path) {
|
|
||||||
const pathParts = path.split("/");
|
|
||||||
if (pathParts.length === 2) {
|
|
||||||
// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package
|
|
||||||
// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package}
|
|
||||||
// Actually, for 2 parts it's owner/package, and repo is same as owner typically
|
|
||||||
const owner = pathParts[0];
|
|
||||||
const packageName = pathParts[1];
|
|
||||||
return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`;
|
|
||||||
} else if (pathParts.length >= 3) {
|
|
||||||
// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package
|
|
||||||
const owner = pathParts[0];
|
|
||||||
const repo = pathParts[1];
|
|
||||||
const packageName = pathParts.slice(2).join("/");
|
|
||||||
return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Legacy GitHub Packages
|
|
||||||
if (domain === "docker.pkg.github.com" && path) {
|
|
||||||
const pathParts = path.split("/");
|
|
||||||
if (pathParts.length >= 1) {
|
|
||||||
return `https://github.com/${pathParts[0]}/packages`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "gitlab":
|
|
||||||
case "registry.gitlab.com": {
|
|
||||||
// GitLab Container Registry
|
|
||||||
if (path) {
|
|
||||||
return `https://gitlab.com/${path}/container_registry`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "google":
|
|
||||||
case "gcr.io": {
|
|
||||||
// Google Container Registry
|
|
||||||
if (domain.includes("gcr.io") || domain.includes("pkg.dev")) {
|
|
||||||
return `https://console.cloud.google.com/gcr/images/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "quay":
|
|
||||||
case "quay.io": {
|
|
||||||
// Quay.io
|
|
||||||
if (path) {
|
|
||||||
return `https://quay.io/repository/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "redhat":
|
|
||||||
case "registry.access.redhat.com": {
|
|
||||||
// Red Hat
|
|
||||||
if (path) {
|
|
||||||
return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "azure":
|
|
||||||
case "azurecr.io": {
|
|
||||||
// Azure Container Registry
|
|
||||||
if (domain.includes("azurecr.io")) {
|
|
||||||
const registryName = domain.split(".")[0];
|
|
||||||
return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "aws":
|
|
||||||
case "amazonaws.com": {
|
|
||||||
// AWS ECR
|
|
||||||
if (domain.includes("amazonaws.com")) {
|
|
||||||
const domainParts = domain.split(".");
|
|
||||||
const region = domainParts[3]; // Extract region
|
|
||||||
return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "private":
|
|
||||||
// For private registries, try to construct a basic URL
|
|
||||||
if (domain) {
|
|
||||||
return `https://${domain}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a user-friendly display name for a registry source
|
|
||||||
* @param {string} source - The source identifier
|
|
||||||
* @returns {string} - Human-readable source name
|
|
||||||
*/
|
|
||||||
export function getSourceDisplayName(source) {
|
|
||||||
const sourceNames = {
|
|
||||||
"docker-hub": "Docker Hub",
|
|
||||||
github: "GitHub",
|
|
||||||
gitlab: "GitLab",
|
|
||||||
google: "Google",
|
|
||||||
quay: "Quay.io",
|
|
||||||
redhat: "Red Hat",
|
|
||||||
azure: "Azure",
|
|
||||||
aws: "AWS ECR",
|
|
||||||
private: "Private Registry",
|
|
||||||
local: "Local",
|
|
||||||
unknown: "Unknown",
|
|
||||||
};
|
|
||||||
|
|
||||||
return sourceNames[source] || source;
|
|
||||||
}
|
|
||||||
709
package-lock.json
generated
709
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "patchmon",
|
"name": "patchmon",
|
||||||
"version": "1.3.2",
|
"version": "1.3.1",
|
||||||
"description": "Linux Patch Monitoring System",
|
"description": "Linux Patch Monitoring System",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
185
setup.sh
185
setup.sh
@@ -34,7 +34,7 @@ BLUE='\033[0;34m'
|
|||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# Global variables
|
# Global variables
|
||||||
SCRIPT_VERSION="self-hosting-install.sh v1.3.2-selfhost-2025-10-31-1"
|
SCRIPT_VERSION="self-hosting-install.sh v1.3.0-selfhost-2025-10-19-1"
|
||||||
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
|
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
|
||||||
FQDN=""
|
FQDN=""
|
||||||
CUSTOM_FQDN=""
|
CUSTOM_FQDN=""
|
||||||
@@ -1797,12 +1797,7 @@ create_agent_version() {
|
|||||||
cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/"
|
cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/"
|
||||||
|
|
||||||
print_status "Agent version management removed - using file-based approach"
|
print_status "Agent version management removed - using file-based approach"
|
||||||
fi
|
# Ensure we close the conditional and the function properly
|
||||||
|
|
||||||
# Make agent binaries executable
|
|
||||||
if [ -d "$APP_DIR/agents" ]; then
|
|
||||||
chmod +x "$APP_DIR/agents/patchmon-agent-linux-"* 2>/dev/null || true
|
|
||||||
print_status "Agent binaries made executable"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
@@ -2197,66 +2192,34 @@ select_installation_to_update() {
|
|||||||
version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
|
version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get service status - search for service files that reference this installation
|
# Get service status - try multiple naming conventions
|
||||||
local service_name=""
|
# Convention 1: Just the install name (e.g., patchmon.internal)
|
||||||
|
local service_name="$install"
|
||||||
|
# Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal)
|
||||||
|
local alt_service_name1="patchmon.$install"
|
||||||
|
# Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal)
|
||||||
|
local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')"
|
||||||
local status="unknown"
|
local status="unknown"
|
||||||
|
|
||||||
# Search systemd directory for service files that reference this installation
|
# Try convention 1 first (most common)
|
||||||
for service_file in /etc/systemd/system/*.service; do
|
if systemctl is-active --quiet "$service_name" 2>/dev/null; then
|
||||||
if [ -f "$service_file" ]; then
|
status="running"
|
||||||
# Check if this service file references our installation directory
|
elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
|
||||||
if grep -q "/opt/$install" "$service_file"; then
|
status="stopped"
|
||||||
service_name=$(basename "$service_file" .service)
|
# Try convention 2
|
||||||
|
elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then
|
||||||
# Check service status
|
status="running"
|
||||||
if systemctl is-active --quiet "$service_name" 2>/dev/null; then
|
service_name="$alt_service_name1"
|
||||||
status="running"
|
elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then
|
||||||
break
|
status="stopped"
|
||||||
elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
|
service_name="$alt_service_name1"
|
||||||
status="stopped"
|
# Try convention 3
|
||||||
break
|
elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then
|
||||||
fi
|
status="running"
|
||||||
fi
|
service_name="$alt_service_name2"
|
||||||
fi
|
elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then
|
||||||
done
|
status="stopped"
|
||||||
|
service_name="$alt_service_name2"
|
||||||
# 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
|
fi
|
||||||
|
|
||||||
printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
|
printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
|
||||||
@@ -2740,13 +2703,6 @@ update_env_file() {
|
|||||||
: ${TFA_MAX_REMEMBER_SESSIONS:=5}
|
: ${TFA_MAX_REMEMBER_SESSIONS:=5}
|
||||||
: ${TFA_SUSPICIOUS_ACTIVITY_THRESHOLD:=3}
|
: ${TFA_SUSPICIOUS_ACTIVITY_THRESHOLD:=3}
|
||||||
|
|
||||||
# Prisma Connection Pool
|
|
||||||
: ${DB_CONNECTION_LIMIT:=30}
|
|
||||||
: ${DB_POOL_TIMEOUT:=20}
|
|
||||||
: ${DB_CONNECT_TIMEOUT:=10}
|
|
||||||
: ${DB_IDLE_TIMEOUT:=300}
|
|
||||||
: ${DB_MAX_LIFETIME:=1800}
|
|
||||||
|
|
||||||
# Track which variables were added
|
# Track which variables were added
|
||||||
local added_vars=()
|
local added_vars=()
|
||||||
|
|
||||||
@@ -2808,21 +2764,6 @@ update_env_file() {
|
|||||||
if ! grep -q "^TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=" "$env_file"; then
|
if ! grep -q "^TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=" "$env_file"; then
|
||||||
added_vars+=("TFA_SUSPICIOUS_ACTIVITY_THRESHOLD")
|
added_vars+=("TFA_SUSPICIOUS_ACTIVITY_THRESHOLD")
|
||||||
fi
|
fi
|
||||||
if ! grep -q "^DB_CONNECTION_LIMIT=" "$env_file"; then
|
|
||||||
added_vars+=("DB_CONNECTION_LIMIT")
|
|
||||||
fi
|
|
||||||
if ! grep -q "^DB_POOL_TIMEOUT=" "$env_file"; then
|
|
||||||
added_vars+=("DB_POOL_TIMEOUT")
|
|
||||||
fi
|
|
||||||
if ! grep -q "^DB_CONNECT_TIMEOUT=" "$env_file"; then
|
|
||||||
added_vars+=("DB_CONNECT_TIMEOUT")
|
|
||||||
fi
|
|
||||||
if ! grep -q "^DB_IDLE_TIMEOUT=" "$env_file"; then
|
|
||||||
added_vars+=("DB_IDLE_TIMEOUT")
|
|
||||||
fi
|
|
||||||
if ! grep -q "^DB_MAX_LIFETIME=" "$env_file"; then
|
|
||||||
added_vars+=("DB_MAX_LIFETIME")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If there are missing variables, add them
|
# If there are missing variables, add them
|
||||||
if [ ${#added_vars[@]} -gt 0 ]; then
|
if [ ${#added_vars[@]} -gt 0 ]; then
|
||||||
@@ -2908,25 +2849,6 @@ EOF
|
|||||||
echo "TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=$TFA_SUSPICIOUS_ACTIVITY_THRESHOLD" >> "$env_file"
|
echo "TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=$TFA_SUSPICIOUS_ACTIVITY_THRESHOLD" >> "$env_file"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Add Prisma connection pool config if missing
|
|
||||||
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_CONNECTION_LIMIT"; then
|
|
||||||
echo "" >> "$env_file"
|
|
||||||
echo "# Database Connection Pool Configuration (Prisma)" >> "$env_file"
|
|
||||||
echo "DB_CONNECTION_LIMIT=$DB_CONNECTION_LIMIT" >> "$env_file"
|
|
||||||
fi
|
|
||||||
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_POOL_TIMEOUT"; then
|
|
||||||
echo "DB_POOL_TIMEOUT=$DB_POOL_TIMEOUT" >> "$env_file"
|
|
||||||
fi
|
|
||||||
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_CONNECT_TIMEOUT"; then
|
|
||||||
echo "DB_CONNECT_TIMEOUT=$DB_CONNECT_TIMEOUT" >> "$env_file"
|
|
||||||
fi
|
|
||||||
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_IDLE_TIMEOUT"; then
|
|
||||||
echo "DB_IDLE_TIMEOUT=$DB_IDLE_TIMEOUT" >> "$env_file"
|
|
||||||
fi
|
|
||||||
if printf '%s\n' "${added_vars[@]}" | grep -q "DB_MAX_LIFETIME"; then
|
|
||||||
echo "DB_MAX_LIFETIME=$DB_MAX_LIFETIME" >> "$env_file"
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_status ".env file updated with ${#added_vars[@]} new variable(s)"
|
print_status ".env file updated with ${#added_vars[@]} new variable(s)"
|
||||||
print_info "Added variables: ${added_vars[*]}"
|
print_info "Added variables: ${added_vars[*]}"
|
||||||
else
|
else
|
||||||
@@ -2996,37 +2918,11 @@ update_installation() {
|
|||||||
print_info "Installation directory: $instance_dir"
|
print_info "Installation directory: $instance_dir"
|
||||||
print_info "Service name: $service_name"
|
print_info "Service name: $service_name"
|
||||||
|
|
||||||
# Verify it's a git repository, if not, initialize it
|
# Verify it's a git repository
|
||||||
if [ ! -d "$instance_dir/.git" ]; then
|
if [ ! -d "$instance_dir/.git" ]; then
|
||||||
print_warning "Installation directory is not a git repository"
|
print_error "Installation directory is not a git repository"
|
||||||
print_info "Attempting to re-initialize as git repository..."
|
print_error "Cannot perform git-based update"
|
||||||
|
exit 1
|
||||||
cd "$instance_dir" || exit 1
|
|
||||||
|
|
||||||
# Initialize git repository
|
|
||||||
git init
|
|
||||||
git remote add origin https://github.com/PatchMon/PatchMon.git
|
|
||||||
|
|
||||||
# Fetch all branches
|
|
||||||
git fetch origin
|
|
||||||
|
|
||||||
# Try to determine current version from package.json or default to main
|
|
||||||
local current_branch="main"
|
|
||||||
if [ -f "$instance_dir/backend/package.json" ]; then
|
|
||||||
local pkg_version=$(grep '"version"' "$instance_dir/backend/package.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/')
|
|
||||||
if [ -n "$pkg_version" ]; then
|
|
||||||
# Check if there's a release branch for this version
|
|
||||||
if git ls-remote --heads origin | grep -q "release/$(echo $pkg_version | sed 's/\./-/g')"; then
|
|
||||||
current_branch="release/$(echo $pkg_version | sed 's/\./-/g')"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Reset to the determined branch
|
|
||||||
git reset --hard "origin/$current_branch"
|
|
||||||
git checkout -B "$current_branch" "origin/$current_branch"
|
|
||||||
|
|
||||||
print_success "Repository initialized successfully"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Add git safe.directory to avoid ownership issues when running as root
|
# Add git safe.directory to avoid ownership issues when running as root
|
||||||
@@ -3035,8 +2931,6 @@ update_installation() {
|
|||||||
|
|
||||||
# Load existing .env to get database credentials
|
# Load existing .env to get database credentials
|
||||||
if [ -f "$instance_dir/backend/.env" ]; then
|
if [ -f "$instance_dir/backend/.env" ]; then
|
||||||
# Unset color variables before sourcing to prevent ANSI escape sequences from leaking into .env
|
|
||||||
unset RED GREEN YELLOW BLUE NC
|
|
||||||
source "$instance_dir/backend/.env"
|
source "$instance_dir/backend/.env"
|
||||||
print_status "Loaded existing configuration"
|
print_status "Loaded existing configuration"
|
||||||
|
|
||||||
@@ -3104,16 +2998,11 @@ update_installation() {
|
|||||||
|
|
||||||
# Clean up any untracked files that might conflict with incoming changes
|
# Clean up any untracked files that might conflict with incoming changes
|
||||||
print_info "Cleaning up untracked files to prevent merge conflicts..."
|
print_info "Cleaning up untracked files to prevent merge conflicts..."
|
||||||
git clean -fd 2>/dev/null || true
|
git clean -fd
|
||||||
|
|
||||||
# Reset any local changes to ensure clean state
|
# 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..."
|
print_info "Resetting local changes to ensure clean state..."
|
||||||
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
git reset --hard HEAD
|
||||||
git reset --hard HEAD
|
|
||||||
else
|
|
||||||
print_warning "HEAD not found, skipping reset (fresh repository or detached state)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fetch latest changes
|
# Fetch latest changes
|
||||||
git fetch origin
|
git fetch origin
|
||||||
@@ -3137,12 +3026,6 @@ update_installation() {
|
|||||||
print_info "Building frontend..."
|
print_info "Building frontend..."
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Make agent binaries executable
|
|
||||||
if [ -d "$instance_dir/agents" ]; then
|
|
||||||
chmod +x "$instance_dir/agents/patchmon-agent-linux-"* 2>/dev/null || true
|
|
||||||
print_status "Agent binaries made executable"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run database migrations with self-healing
|
# Run database migrations with self-healing
|
||||||
print_info "Running database migrations..."
|
print_info "Running database migrations..."
|
||||||
cd "$instance_dir/backend"
|
cd "$instance_dir/backend"
|
||||||
|
|||||||
Reference in New Issue
Block a user