Made changes to the host details area to add notes

Reconfigured JWT session timeouts
This commit is contained in:
Muhammad Ibrahim
2025-10-01 08:38:40 +01:00
parent f254b54404
commit 5d8a1e71d6
13 changed files with 1004 additions and 299 deletions

View File

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

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "notes" TEXT;

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
};

View File

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

View File

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

View File

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