mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-23 07:42:05 +00:00
Made changes to the host details area to add notes
Reconfigured JWT session timeouts
This commit is contained in:
@@ -23,3 +23,9 @@ ENABLE_LOGGING=true
|
||||
|
||||
# User Registration
|
||||
DEFAULT_USER_ROLE=user
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "hosts" ADD COLUMN "notes" TEXT;
|
||||
|
31
backend/prisma/migrations/add_user_sessions/migration.sql
Normal file
31
backend/prisma/migrations/add_user_sessions/migration.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"refresh_token" TEXT NOT NULL,
|
||||
"access_token_hash" TEXT,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"last_activity" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"is_revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_sessions_refresh_token_key" ON "user_sessions"("refresh_token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_sessions_user_id_idx" ON "user_sessions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_sessions_refresh_token_idx" ON "user_sessions"("refresh_token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_sessions_expires_at_idx" ON "user_sessions"("expires_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
@@ -86,6 +86,7 @@ model hosts {
|
||||
selinux_status String?
|
||||
swap_size Int?
|
||||
system_uptime String?
|
||||
notes String?
|
||||
host_packages host_packages[]
|
||||
host_repositories host_repositories[]
|
||||
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
|
||||
@@ -186,4 +187,23 @@ model users {
|
||||
first_name String?
|
||||
last_name String?
|
||||
dashboard_preferences dashboard_preferences[]
|
||||
user_sessions user_sessions[]
|
||||
}
|
||||
|
||||
model user_sessions {
|
||||
id String @id
|
||||
user_id String
|
||||
refresh_token String @unique
|
||||
access_token_hash String?
|
||||
ip_address String?
|
||||
user_agent String?
|
||||
last_activity DateTime @default(now())
|
||||
expires_at DateTime
|
||||
created_at DateTime @default(now())
|
||||
is_revoked Boolean @default(false)
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([user_id])
|
||||
@@index([refresh_token])
|
||||
@@index([expires_at])
|
||||
}
|
||||
|
@@ -1,9 +1,13 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const {
|
||||
validate_session,
|
||||
update_session_activity,
|
||||
} = require("../utils/session_manager");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Middleware to verify JWT token
|
||||
// Middleware to verify JWT token with session validation
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -19,35 +23,40 @@ const authenticateToken = async (req, res, next) => {
|
||||
process.env.JWT_SECRET || "your-secret-key",
|
||||
);
|
||||
|
||||
// Get user from database
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
is_active: true,
|
||||
last_login: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
// Validate session and check inactivity timeout
|
||||
const validation = await validate_session(decoded.sessionId, token);
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ error: "Invalid or inactive user" });
|
||||
if (!validation.valid) {
|
||||
const error_messages = {
|
||||
"Session not found": "Session not found",
|
||||
"Session revoked": "Session has been revoked",
|
||||
"Session expired": "Session has expired",
|
||||
"Session inactive":
|
||||
validation.message || "Session timed out due to inactivity",
|
||||
"Token mismatch": "Invalid token",
|
||||
"User inactive": "User account is inactive",
|
||||
};
|
||||
|
||||
return res.status(401).json({
|
||||
error: error_messages[validation.reason] || "Authentication failed",
|
||||
reason: validation.reason,
|
||||
});
|
||||
}
|
||||
|
||||
// Update last login
|
||||
// Update session activity timestamp
|
||||
await update_session_activity(decoded.sessionId);
|
||||
|
||||
// Update last login (only on successful authentication)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
where: { id: validation.user.id },
|
||||
data: {
|
||||
last_login: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
req.user = user;
|
||||
req.user = validation.user;
|
||||
req.session_id = decoded.sessionId;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === "JsonWebTokenError") {
|
||||
|
@@ -12,6 +12,13 @@ const { v4: uuidv4 } = require("uuid");
|
||||
const {
|
||||
createDefaultDashboardPreferences,
|
||||
} = require("./dashboardPreferencesRoutes");
|
||||
const {
|
||||
create_session,
|
||||
refresh_access_token,
|
||||
revoke_session,
|
||||
revoke_all_user_sessions,
|
||||
get_user_sessions,
|
||||
} = require("../utils/session_manager");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
@@ -118,12 +125,16 @@ router.post(
|
||||
// Create default dashboard preferences for the new admin user
|
||||
await createDefaultDashboardPreferences(user.id, "admin");
|
||||
|
||||
// Generate token for immediate login
|
||||
const token = generateToken(user.id);
|
||||
// Create session for immediate login
|
||||
const ip_address = req.ip || req.connection.remoteAddress;
|
||||
const user_agent = req.get("user-agent");
|
||||
const session = await create_session(user.id, ip_address, user_agent);
|
||||
|
||||
res.status(201).json({
|
||||
message: "Admin user created successfully",
|
||||
token,
|
||||
token: session.access_token,
|
||||
refresh_token: session.refresh_token,
|
||||
expires_at: session.expires_at,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -722,12 +733,16 @@ router.post(
|
||||
},
|
||||
});
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user.id);
|
||||
// Create session with access and refresh tokens
|
||||
const ip_address = req.ip || req.connection.remoteAddress;
|
||||
const user_agent = req.get("user-agent");
|
||||
const session = await create_session(user.id, ip_address, user_agent);
|
||||
|
||||
res.json({
|
||||
message: "Login successful",
|
||||
token,
|
||||
token: session.access_token,
|
||||
refresh_token: session.refresh_token,
|
||||
expires_at: session.expires_at,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -829,12 +844,16 @@ router.post(
|
||||
data: { last_login: new Date() },
|
||||
});
|
||||
|
||||
// Generate token
|
||||
const jwtToken = generateToken(user.id);
|
||||
// Create session with access and refresh tokens
|
||||
const ip_address = req.ip || req.connection.remoteAddress;
|
||||
const user_agent = req.get("user-agent");
|
||||
const session = await create_session(user.id, ip_address, user_agent);
|
||||
|
||||
res.json({
|
||||
message: "Login successful",
|
||||
token: jwtToken,
|
||||
token: session.access_token,
|
||||
refresh_token: session.refresh_token,
|
||||
expires_at: session.expires_at,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -1001,9 +1020,14 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Logout (client-side token removal)
|
||||
router.post("/logout", authenticateToken, async (_req, res) => {
|
||||
// Logout (revoke current session)
|
||||
router.post("/logout", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// Revoke the current session
|
||||
if (req.session_id) {
|
||||
await revoke_session(req.session_id);
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "Logout successful",
|
||||
});
|
||||
@@ -1013,4 +1037,94 @@ router.post("/logout", authenticateToken, async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Logout all sessions (revoke all user sessions)
|
||||
router.post("/logout-all", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
await revoke_all_user_sessions(req.user.id);
|
||||
|
||||
res.json({
|
||||
message: "All sessions logged out successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Logout all error:", error);
|
||||
res.status(500).json({ error: "Logout all failed" });
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh access token using refresh token
|
||||
router.post(
|
||||
"/refresh-token",
|
||||
[body("refresh_token").notEmpty().withMessage("Refresh token is required")],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { refresh_token } = req.body;
|
||||
|
||||
const result = await refresh_access_token(refresh_token);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(401).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "Token refreshed successfully",
|
||||
token: result.access_token,
|
||||
user: {
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
email: result.user.email,
|
||||
role: result.user.role,
|
||||
is_active: result.user.is_active,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Refresh token error:", error);
|
||||
res.status(500).json({ error: "Token refresh failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get user's active sessions
|
||||
router.get("/sessions", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const sessions = await get_user_sessions(req.user.id);
|
||||
|
||||
res.json({
|
||||
sessions: sessions,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Get sessions error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch sessions" });
|
||||
}
|
||||
});
|
||||
|
||||
// Revoke a specific session
|
||||
router.delete("/sessions/:session_id", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { session_id } = req.params;
|
||||
|
||||
// Verify the session belongs to the user
|
||||
const session = await prisma.user_sessions.findUnique({
|
||||
where: { id: session_id },
|
||||
});
|
||||
|
||||
if (!session || session.user_id !== req.user.id) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
|
||||
await revoke_session(session_id);
|
||||
|
||||
res.json({
|
||||
message: "Session revoked successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Revoke session error:", error);
|
||||
res.status(500).json({ error: "Failed to revoke session" });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
@@ -194,6 +194,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
||||
status: true,
|
||||
agent_version: true,
|
||||
auto_update: true,
|
||||
notes: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
|
@@ -858,6 +858,7 @@ router.get(
|
||||
auto_update: true,
|
||||
created_at: true,
|
||||
host_group_id: true,
|
||||
notes: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -1491,4 +1492,78 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
// Update host notes (admin only)
|
||||
router.patch(
|
||||
"/:hostId/notes",
|
||||
authenticateToken,
|
||||
requireManageHosts,
|
||||
[
|
||||
body("notes")
|
||||
.optional()
|
||||
.isLength({ max: 1000 })
|
||||
.withMessage("Notes must be less than 1000 characters"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostId } = req.params;
|
||||
const { notes } = req.body;
|
||||
|
||||
// Check if host exists
|
||||
const existingHost = await prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
});
|
||||
|
||||
if (!existingHost) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
// Update the notes
|
||||
const updatedHost = await prisma.hosts.update({
|
||||
where: { id: hostId },
|
||||
data: {
|
||||
notes: notes || null,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
os_type: true,
|
||||
os_version: true,
|
||||
architecture: true,
|
||||
last_update: true,
|
||||
status: true,
|
||||
host_group_id: true,
|
||||
agent_version: true,
|
||||
auto_update: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
notes: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "Notes updated successfully",
|
||||
host: updatedHost,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Update notes error:", error);
|
||||
res.status(500).json({ error: "Failed to update notes" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
@@ -26,6 +26,7 @@ const versionRoutes = require("./routes/versionRoutes");
|
||||
const tfaRoutes = require("./routes/tfaRoutes");
|
||||
const updateScheduler = require("./services/updateScheduler");
|
||||
const { initSettings } = require("./services/settingsService");
|
||||
const { cleanup_expired_sessions } = require("./utils/session_manager");
|
||||
|
||||
// Initialize Prisma client with optimized connection pooling for multiple instances
|
||||
const prisma = createPrismaClient();
|
||||
@@ -399,6 +400,9 @@ process.on("SIGINT", async () => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.info("SIGINT received, shutting down gracefully");
|
||||
}
|
||||
if (app.locals.session_cleanup_interval) {
|
||||
clearInterval(app.locals.session_cleanup_interval);
|
||||
}
|
||||
updateScheduler.stop();
|
||||
await disconnectPrisma(prisma);
|
||||
process.exit(0);
|
||||
@@ -408,6 +412,9 @@ process.on("SIGTERM", async () => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.info("SIGTERM received, shutting down gracefully");
|
||||
}
|
||||
if (app.locals.session_cleanup_interval) {
|
||||
clearInterval(app.locals.session_cleanup_interval);
|
||||
}
|
||||
updateScheduler.stop();
|
||||
await disconnectPrisma(prisma);
|
||||
process.exit(0);
|
||||
@@ -671,15 +678,35 @@ async function startServer() {
|
||||
|
||||
// Initialize dashboard preferences for all users
|
||||
await initializeDashboardPreferences();
|
||||
|
||||
// Initial session cleanup
|
||||
await cleanup_expired_sessions();
|
||||
|
||||
// Schedule session cleanup every hour
|
||||
const session_cleanup_interval = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
await cleanup_expired_sessions();
|
||||
} catch (error) {
|
||||
console.error("Session cleanup error:", error);
|
||||
}
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
); // Every hour
|
||||
|
||||
app.listen(PORT, () => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Environment: ${process.env.NODE_ENV}`);
|
||||
logger.info("✅ Session cleanup scheduled (every hour)");
|
||||
}
|
||||
|
||||
// Start update scheduler
|
||||
updateScheduler.start();
|
||||
});
|
||||
|
||||
// Store interval for cleanup on shutdown
|
||||
app.locals.session_cleanup_interval = session_cleanup_interval;
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to start server:", error.message);
|
||||
process.exit(1);
|
||||
|
319
backend/src/utils/session_manager.js
Normal file
319
backend/src/utils/session_manager.js
Normal file
@@ -0,0 +1,319 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const crypto = require("crypto");
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Session Manager - Handles secure session management with inactivity timeout
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
|
||||
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
|
||||
const INACTIVITY_TIMEOUT_MINUTES = parseInt(
|
||||
process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30",
|
||||
10,
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate access token (short-lived)
|
||||
*/
|
||||
function generate_access_token(user_id, session_id) {
|
||||
return jwt.sign({ userId: user_id, sessionId: session_id }, JWT_SECRET, {
|
||||
expiresIn: JWT_EXPIRES_IN,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token (long-lived)
|
||||
*/
|
||||
function generate_refresh_token() {
|
||||
return crypto.randomBytes(64).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash token for storage
|
||||
*/
|
||||
function hash_token(token) {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse expiration string to Date
|
||||
*/
|
||||
function parse_expiration(expiration_string) {
|
||||
const match = expiration_string.match(/^(\d+)([smhd])$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid expiration format");
|
||||
}
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
const now = new Date();
|
||||
switch (unit) {
|
||||
case "s":
|
||||
return new Date(now.getTime() + value * 1000);
|
||||
case "m":
|
||||
return new Date(now.getTime() + value * 60 * 1000);
|
||||
case "h":
|
||||
return new Date(now.getTime() + value * 60 * 60 * 1000);
|
||||
case "d":
|
||||
return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
|
||||
default:
|
||||
throw new Error("Invalid time unit");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session for user
|
||||
*/
|
||||
async function create_session(user_id, ip_address, user_agent) {
|
||||
try {
|
||||
const session_id = crypto.randomUUID();
|
||||
const refresh_token = generate_refresh_token();
|
||||
const access_token = generate_access_token(user_id, session_id);
|
||||
|
||||
const expires_at = parse_expiration(JWT_REFRESH_EXPIRES_IN);
|
||||
|
||||
// Store session in database
|
||||
await prisma.user_sessions.create({
|
||||
data: {
|
||||
id: session_id,
|
||||
user_id: user_id,
|
||||
refresh_token: hash_token(refresh_token),
|
||||
access_token_hash: hash_token(access_token),
|
||||
ip_address: ip_address || null,
|
||||
user_agent: user_agent || null,
|
||||
last_activity: new Date(),
|
||||
expires_at: expires_at,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
session_id,
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating session:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session and check for inactivity timeout
|
||||
*/
|
||||
async function validate_session(session_id, access_token) {
|
||||
try {
|
||||
const session = await prisma.user_sessions.findUnique({
|
||||
where: { id: session_id },
|
||||
include: { users: true },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return { valid: false, reason: "Session not found" };
|
||||
}
|
||||
|
||||
// Check if session is revoked
|
||||
if (session.is_revoked) {
|
||||
return { valid: false, reason: "Session revoked" };
|
||||
}
|
||||
|
||||
// Check if session has expired
|
||||
if (new Date() > session.expires_at) {
|
||||
await revoke_session(session_id);
|
||||
return { valid: false, reason: "Session expired" };
|
||||
}
|
||||
|
||||
// Check for inactivity timeout
|
||||
const inactivity_threshold = new Date(
|
||||
Date.now() - INACTIVITY_TIMEOUT_MINUTES * 60 * 1000,
|
||||
);
|
||||
if (session.last_activity < inactivity_threshold) {
|
||||
await revoke_session(session_id);
|
||||
return {
|
||||
valid: false,
|
||||
reason: "Session inactive",
|
||||
message: `Session timed out after ${INACTIVITY_TIMEOUT_MINUTES} minutes of inactivity`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate access token hash (optional security check)
|
||||
if (session.access_token_hash) {
|
||||
const provided_hash = hash_token(access_token);
|
||||
if (session.access_token_hash !== provided_hash) {
|
||||
return { valid: false, reason: "Token mismatch" };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is still active
|
||||
if (!session.users.is_active) {
|
||||
await revoke_session(session_id);
|
||||
return { valid: false, reason: "User inactive" };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
session,
|
||||
user: session.users,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error validating session:", error);
|
||||
return { valid: false, reason: "Validation error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session activity timestamp
|
||||
*/
|
||||
async function update_session_activity(session_id) {
|
||||
try {
|
||||
await prisma.user_sessions.update({
|
||||
where: { id: session_id },
|
||||
data: { last_activity: new Date() },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error updating session activity:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async function refresh_access_token(refresh_token) {
|
||||
try {
|
||||
const hashed_token = hash_token(refresh_token);
|
||||
|
||||
const session = await prisma.user_sessions.findUnique({
|
||||
where: { refresh_token: hashed_token },
|
||||
include: { users: true },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return { success: false, error: "Invalid refresh token" };
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const validation = await validate_session(session.id, "");
|
||||
if (!validation.valid) {
|
||||
return { success: false, error: validation.reason };
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const new_access_token = generate_access_token(session.user_id, session.id);
|
||||
|
||||
// Update access token hash
|
||||
await prisma.user_sessions.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
access_token_hash: hash_token(new_access_token),
|
||||
last_activity: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
access_token: new_access_token,
|
||||
user: session.users,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error refreshing access token:", error);
|
||||
return { success: false, error: "Token refresh failed" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a session
|
||||
*/
|
||||
async function revoke_session(session_id) {
|
||||
try {
|
||||
await prisma.user_sessions.update({
|
||||
where: { id: session_id },
|
||||
data: { is_revoked: true },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error revoking session:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all sessions for a user
|
||||
*/
|
||||
async function revoke_all_user_sessions(user_id) {
|
||||
try {
|
||||
await prisma.user_sessions.updateMany({
|
||||
where: { user_id: user_id },
|
||||
data: { is_revoked: true },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error revoking user sessions:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions (should be run periodically)
|
||||
*/
|
||||
async function cleanup_expired_sessions() {
|
||||
try {
|
||||
const result = await prisma.user_sessions.deleteMany({
|
||||
where: {
|
||||
OR: [{ expires_at: { lt: new Date() } }, { is_revoked: true }],
|
||||
},
|
||||
});
|
||||
console.log(`Cleaned up ${result.count} expired sessions`);
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up sessions:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions for a user
|
||||
*/
|
||||
async function get_user_sessions(user_id) {
|
||||
try {
|
||||
return await prisma.user_sessions.findMany({
|
||||
where: {
|
||||
user_id: user_id,
|
||||
is_revoked: false,
|
||||
expires_at: { gt: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
ip_address: true,
|
||||
user_agent: true,
|
||||
last_activity: true,
|
||||
created_at: true,
|
||||
expires_at: true,
|
||||
},
|
||||
orderBy: { last_activity: "desc" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error getting user sessions:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
create_session,
|
||||
validate_session,
|
||||
update_session_activity,
|
||||
refresh_access_token,
|
||||
revoke_session,
|
||||
revoke_all_user_sessions,
|
||||
cleanup_expired_sessions,
|
||||
get_user_sessions,
|
||||
generate_access_token,
|
||||
INACTIVITY_TIMEOUT_MINUTES,
|
||||
};
|
@@ -102,6 +102,15 @@ const HostDetail = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateNotesMutation = useMutation({
|
||||
mutationFn: ({ hostId, notes }) =>
|
||||
adminHostsAPI.updateNotes(hostId, notes).then((res) => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["host", hostId]);
|
||||
queryClient.invalidateQueries(["hosts"]);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteHost = async () => {
|
||||
if (
|
||||
window.confirm(
|
||||
@@ -315,17 +324,6 @@ const HostDetail = () => {
|
||||
>
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("monitoring")}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
activeTab === "monitoring"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
}`}
|
||||
>
|
||||
Resource
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("history")}
|
||||
@@ -335,7 +333,18 @@ const HostDetail = () => {
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
}`}
|
||||
>
|
||||
Update History
|
||||
Agent History
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("notes")}
|
||||
className={`px-4 py-2 text-sm font-medium ${
|
||||
activeTab === "notes"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
|
||||
}`}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -506,55 +515,279 @@ const HostDetail = () => {
|
||||
)}
|
||||
|
||||
{/* System Information */}
|
||||
{activeTab === "system" &&
|
||||
(host.kernel_version ||
|
||||
host.selinux_status ||
|
||||
host.architecture) && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{host.architecture && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Architecture
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.architecture}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "system" && (
|
||||
<div className="space-y-6">
|
||||
{/* Basic System Information */}
|
||||
{(host.kernel_version ||
|
||||
host.selinux_status ||
|
||||
host.architecture) && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
System Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{host.architecture && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Architecture
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.architecture}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{host.kernel_version && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Kernel Version
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
|
||||
{host.kernel_version}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{host.kernel_version && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Kernel Version
|
||||
</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
|
||||
{host.kernel_version}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{host.selinux_status && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
SELinux Status
|
||||
</p>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
host.selinux_status === "enabled"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: host.selinux_status === "permissive"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{host.selinux_status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Empty div to push SELinux status to the right */}
|
||||
<div></div>
|
||||
|
||||
{host.selinux_status && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
SELinux Status
|
||||
</p>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
host.selinux_status === "enabled"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: host.selinux_status === "permissive"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{host.selinux_status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Resource Information */}
|
||||
{(host.system_uptime ||
|
||||
host.cpu_model ||
|
||||
host.cpu_cores ||
|
||||
host.ram_installed ||
|
||||
host.swap_size !== undefined ||
|
||||
(host.load_average &&
|
||||
Array.isArray(host.load_average) &&
|
||||
host.load_average.length > 0 &&
|
||||
host.load_average.some((load) => load != null)) ||
|
||||
(host.disk_details &&
|
||||
Array.isArray(host.disk_details) &&
|
||||
host.disk_details.length > 0)) && (
|
||||
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
Resource Information
|
||||
</h4>
|
||||
|
||||
{/* System Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{/* System Uptime */}
|
||||
{host.system_uptime && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
System Uptime
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.system_uptime}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CPU Model */}
|
||||
{host.cpu_model && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
CPU Model
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.cpu_model}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CPU Cores */}
|
||||
{host.cpu_cores && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
CPU Cores
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.cpu_cores}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RAM Installed */}
|
||||
{host.ram_installed && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
RAM Installed
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.ram_installed} GB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Swap Size */}
|
||||
{host.swap_size !== undefined &&
|
||||
host.swap_size !== null && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Swap Size
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.swap_size} GB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load Average */}
|
||||
{host.load_average &&
|
||||
Array.isArray(host.load_average) &&
|
||||
host.load_average.length > 0 &&
|
||||
host.load_average.some((load) => load != null) && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Load Average
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.load_average
|
||||
.filter((load) => load != null)
|
||||
.map((load, index) => (
|
||||
<span key={`load-${index}-${load}`}>
|
||||
{typeof load === "number"
|
||||
? load.toFixed(2)
|
||||
: String(load)}
|
||||
{index <
|
||||
host.load_average.filter(
|
||||
(load) => load != null,
|
||||
).length -
|
||||
1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disk Information */}
|
||||
{host.disk_details &&
|
||||
Array.isArray(host.disk_details) &&
|
||||
host.disk_details.length > 0 && (
|
||||
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
Disk Usage
|
||||
</h5>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{host.disk_details.map((disk, index) => (
|
||||
<div
|
||||
key={disk.name || `disk-${index}`}
|
||||
className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<HardDrive className="h-4 w-4 text-secondary-500" />
|
||||
<span className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{disk.name || `Disk ${index + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
{disk.size && (
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
|
||||
Size: {disk.size}
|
||||
</p>
|
||||
)}
|
||||
{disk.mountpoint && (
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
|
||||
Mount: {disk.mountpoint}
|
||||
</p>
|
||||
)}
|
||||
{disk.usage &&
|
||||
typeof disk.usage === "number" && (
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between text-xs text-secondary-600 dark:text-secondary-300 mb-1">
|
||||
<span>Usage</span>
|
||||
<span>{disk.usage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary-200 dark:bg-secondary-600 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 dark:bg-primary-400 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(Math.max(disk.usage, 0), 100)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Data State */}
|
||||
{!host.kernel_version &&
|
||||
!host.selinux_status &&
|
||||
!host.architecture &&
|
||||
!host.system_uptime &&
|
||||
!host.cpu_model &&
|
||||
!host.cpu_cores &&
|
||||
!host.ram_installed &&
|
||||
host.swap_size === undefined &&
|
||||
(!host.load_average ||
|
||||
!Array.isArray(host.load_average) ||
|
||||
host.load_average.length === 0 ||
|
||||
!host.load_average.some((load) => load != null)) &&
|
||||
(!host.disk_details ||
|
||||
!Array.isArray(host.disk_details) ||
|
||||
host.disk_details.length === 0) && (
|
||||
<div className="text-center py-8">
|
||||
<Terminal className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
No system information available
|
||||
</p>
|
||||
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
|
||||
System information will appear once the agent collects
|
||||
data from this host
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "network" &&
|
||||
!(
|
||||
@@ -570,213 +803,6 @@ const HostDetail = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "system" &&
|
||||
!(
|
||||
host.kernel_version ||
|
||||
host.selinux_status ||
|
||||
host.architecture
|
||||
) && (
|
||||
<div className="text-center py-8">
|
||||
<Terminal className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
No system information available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Monitoring */}
|
||||
{activeTab === "monitoring" && (
|
||||
<div className="space-y-6">
|
||||
{/* System Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* System Uptime */}
|
||||
{host.system_uptime && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
System Uptime
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.system_uptime}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CPU Model */}
|
||||
{host.cpu_model && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
CPU Model
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.cpu_model}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CPU Cores */}
|
||||
{host.cpu_cores && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
CPU Cores
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.cpu_cores}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RAM Installed */}
|
||||
{host.ram_installed && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
RAM Installed
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.ram_installed} GB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Swap Size */}
|
||||
{host.swap_size !== undefined &&
|
||||
host.swap_size !== null && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Swap Size
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.swap_size} GB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load Average */}
|
||||
{host.load_average &&
|
||||
Array.isArray(host.load_average) &&
|
||||
host.load_average.length > 0 &&
|
||||
host.load_average.some((load) => load != null) && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
Load Average
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{host.load_average
|
||||
.filter((load) => load != null)
|
||||
.map((load, index) => (
|
||||
<span key={`load-${index}-${load}`}>
|
||||
{typeof load === "number"
|
||||
? load.toFixed(2)
|
||||
: String(load)}
|
||||
{index <
|
||||
host.load_average.filter(
|
||||
(load) => load != null,
|
||||
).length -
|
||||
1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disk Information */}
|
||||
{host.disk_details &&
|
||||
Array.isArray(host.disk_details) &&
|
||||
host.disk_details.length > 0 && (
|
||||
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
Disk Usage
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{host.disk_details.map((disk, index) => (
|
||||
<div
|
||||
key={disk.name || `disk-${index}`}
|
||||
className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<HardDrive className="h-4 w-4 text-secondary-500" />
|
||||
<span className="font-medium text-secondary-900 dark:text-white text-sm">
|
||||
{disk.name || `Disk ${index + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
{disk.size && (
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
|
||||
Size: {disk.size}
|
||||
</p>
|
||||
)}
|
||||
{disk.mountpoint && (
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">
|
||||
Mount: {disk.mountpoint}
|
||||
</p>
|
||||
)}
|
||||
{disk.usage && typeof disk.usage === "number" && (
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between text-xs text-secondary-600 dark:text-secondary-300 mb-1">
|
||||
<span>Usage</span>
|
||||
<span>{disk.usage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-secondary-200 dark:bg-secondary-600 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 dark:bg-primary-400 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(Math.max(disk.usage, 0), 100)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Data State */}
|
||||
{!host.system_uptime &&
|
||||
!host.cpu_model &&
|
||||
!host.cpu_cores &&
|
||||
!host.ram_installed &&
|
||||
host.swap_size === undefined &&
|
||||
(!host.load_average ||
|
||||
!Array.isArray(host.load_average) ||
|
||||
host.load_average.length === 0 ||
|
||||
!host.load_average.some((load) => load != null)) &&
|
||||
(!host.disk_details ||
|
||||
!Array.isArray(host.disk_details) ||
|
||||
host.disk_details.length === 0) && (
|
||||
<div className="text-center py-8">
|
||||
<Monitor className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
No monitoring data available
|
||||
</p>
|
||||
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
|
||||
Monitoring data will appear once the agent collects
|
||||
system information
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update History */}
|
||||
{activeTab === "history" && (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -883,6 +909,56 @@ const HostDetail = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{activeTab === "notes" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Host Notes
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
|
||||
<textarea
|
||||
value={host.notes || ""}
|
||||
onChange={(e) => {
|
||||
// Update local state immediately for better UX
|
||||
const updatedHost = { ...host, notes: e.target.value };
|
||||
queryClient.setQueryData(["host", hostId], updatedHost);
|
||||
}}
|
||||
placeholder="Add notes about this host... (e.g., purpose, special configurations, maintenance notes)"
|
||||
className="w-full h-32 p-3 border border-secondary-200 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none"
|
||||
maxLength={1000}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Use this space to add important information about this
|
||||
host for your team
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500">
|
||||
{(host.notes || "").length}/1000
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateNotesMutation.mutate({
|
||||
hostId: host.id,
|
||||
notes: host.notes || "",
|
||||
});
|
||||
}}
|
||||
disabled={updateNotesMutation.isPending}
|
||||
className="px-3 py-1.5 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:bg-primary-400 rounded-md transition-colors"
|
||||
>
|
||||
{updateNotesMutation.isPending
|
||||
? "Saving..."
|
||||
: "Save Notes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -341,8 +341,9 @@ const Hosts = () => {
|
||||
},
|
||||
{ id: "status", label: "Status", visible: true, order: 8 },
|
||||
{ id: "updates", label: "Updates", visible: true, order: 9 },
|
||||
{ id: "last_update", label: "Last Update", visible: true, order: 10 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 11 },
|
||||
{ id: "notes", label: "Notes", visible: false, order: 10 },
|
||||
{ id: "last_update", label: "Last Update", visible: true, order: 11 },
|
||||
{ id: "actions", label: "Actions", visible: true, order: 12 },
|
||||
];
|
||||
|
||||
const saved = localStorage.getItem("hosts-column-config");
|
||||
@@ -542,7 +543,8 @@ const Hosts = () => {
|
||||
searchTerm === "" ||
|
||||
host.friendly_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// Group filter
|
||||
const matchesGroup =
|
||||
@@ -628,6 +630,10 @@ const Hosts = () => {
|
||||
aValue = new Date(a.last_update);
|
||||
bValue = new Date(b.last_update);
|
||||
break;
|
||||
case "notes":
|
||||
aValue = (a.notes || "").toLowerCase();
|
||||
bValue = (b.notes || "").toLowerCase();
|
||||
break;
|
||||
default:
|
||||
aValue = a[sortField];
|
||||
bValue = b[sortField];
|
||||
@@ -877,6 +883,20 @@ const Hosts = () => {
|
||||
{formatRelativeTime(host.last_update)}
|
||||
</div>
|
||||
);
|
||||
case "notes":
|
||||
return (
|
||||
<div className="text-sm text-secondary-900 dark:text-white max-w-xs">
|
||||
{host.notes ? (
|
||||
<div className="truncate" title={host.notes}>
|
||||
{host.notes}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-secondary-400 dark:text-secondary-500 italic">
|
||||
No notes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<Link
|
||||
|
@@ -74,6 +74,10 @@ export const adminHostsAPI = {
|
||||
api.patch(`/hosts/${hostId}/friendly-name`, {
|
||||
friendly_name: friendlyName,
|
||||
}),
|
||||
updateNotes: (hostId, notes) =>
|
||||
api.patch(`/hosts/${hostId}/notes`, {
|
||||
notes: notes,
|
||||
}),
|
||||
};
|
||||
|
||||
// Host Groups API
|
||||
|
Reference in New Issue
Block a user