From f119cca1d7faa3750b35705e49dff4f754ebe87c Mon Sep 17 00:00:00 2001 From: Jordan Ramos Date: Tue, 23 Jun 2026 12:16:40 -0600 Subject: [PATCH] Add recent activity feed and tabbed sidebar layout New features: - Recent Activity feed widget shows last 8 actions from audit log with relative timestamps, auto-refreshes every 60s - Right sidebar reorganized: Calendar + Activity always visible, Tickets/Archer/Ivanti behind tab switcher to eliminate dead space Backend: - New GET /api/recent-activity endpoint (any authenticated user) Returns last N audit entries excluding login/logout noise Lighter than the full admin audit-logs endpoint Frontend: - RecentActivityFeed component with action labels, colored dots, timeAgo formatting, and manual refresh button - SidebarTabs component with Tickets/Archer/Ivanti tabs - OpenTicketsPanel and IvantiWorkflowPanel support embedded prop to render without their own panel wrapper when inside tabs Layout change: Before: Calendar | Tickets | Archer | Ivanti (4 stacked panels) After: Calendar | Activity | [Tickets | Archer | Ivanti] (tabs) This keeps the sidebar height proportional to the CVE list area instead of extending far below the main content. --- backend/server.js | 19 +++ .../src/components/IvantiWorkflowPanel.js | 11 +- frontend/src/components/OpenTicketsPanel.js | 11 +- frontend/src/components/RecentActivityFeed.js | 158 ++++++++++++++++++ frontend/src/components/SidebarTabs.js | 52 ++++++ frontend/src/components/pages/HomePage.js | 50 +++--- 6 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/RecentActivityFeed.js create mode 100644 frontend/src/components/SidebarTabs.js diff --git a/backend/server.js b/backend/server.js index db06dcc..6be7b7d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -157,6 +157,25 @@ app.use('/api/users', createUsersRouter(requireAuth, requireGroup, logAudit)); // Audit log routes (admin only) app.use('/api/audit-logs', createAuditLogRouter()); +// Recent activity feed (any authenticated user, limited data) +app.get('/api/recent-activity', requireAuth(), async (req, res) => { + try { + const limit = Math.min(15, Math.max(1, parseInt(req.query.limit) || 10)); + const { rows } = await pool.query( + `SELECT username, action, entity_type, entity_id, details, created_at + FROM audit_logs + WHERE action NOT IN ('login', 'logout', 'login_failed') + ORDER BY created_at DESC + LIMIT $1`, + [limit] + ); + res.json({ activities: rows }); + } catch (err) { + console.error('Recent activity error:', err); + res.status(500).json({ error: 'Failed to fetch recent activity' }); + } +}); + // NVD lookup routes (authenticated users) app.use('/api/nvd', createNvdLookupRouter()); diff --git a/frontend/src/components/IvantiWorkflowPanel.js b/frontend/src/components/IvantiWorkflowPanel.js index 85c6f3d..370626a 100644 --- a/frontend/src/components/IvantiWorkflowPanel.js +++ b/frontend/src/components/IvantiWorkflowPanel.js @@ -5,7 +5,7 @@ import ArchiveSummaryBar from './pages/ArchiveSummaryBar'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; -export default function IvantiWorkflowPanel() { +export default function IvantiWorkflowPanel({ embedded = false }) { const { canWrite, getActiveTeamsParam } = useAuth(); const [total, setTotal] = useState(null); const [workflows, setWorkflows] = useState([]); @@ -75,8 +75,8 @@ export default function IvantiWorkflowPanel() { useEffect(() => { fetchWorkflows(); }, [fetchWorkflows]); - return ( -
+ const content = ( + <>

@@ -219,6 +219,9 @@ export default function IvantiWorkflowPanel() {

)} -
+ ); + + if (embedded) return content; + return
{content}
; } diff --git a/frontend/src/components/OpenTicketsPanel.js b/frontend/src/components/OpenTicketsPanel.js index 07fefcb..088c671 100644 --- a/frontend/src/components/OpenTicketsPanel.js +++ b/frontend/src/components/OpenTicketsPanel.js @@ -16,12 +16,12 @@ function getTicketStatusDotClass(status) { return 'glow-dot--medium'; } -export default function OpenTicketsPanel({ tickets, onAdd, onEdit, onDelete }) { +export default function OpenTicketsPanel({ tickets, onAdd, onEdit, onDelete, embedded = false }) { const { canWrite, canDelete } = useAuth(); const openTickets = tickets.filter(t => !isClosedStatus(t.status)); - return ( -
+ const content = ( + <>

@@ -86,6 +86,9 @@ export default function OpenTicketsPanel({ tickets, onAdd, onEdit, onDelete }) {

)}
- + ); + + if (embedded) return content; + return
{content}
; } diff --git a/frontend/src/components/RecentActivityFeed.js b/frontend/src/components/RecentActivityFeed.js new file mode 100644 index 0000000..e639da1 --- /dev/null +++ b/frontend/src/components/RecentActivityFeed.js @@ -0,0 +1,158 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Activity, RefreshCw } from 'lucide-react'; + +// ⚠️ CONVENTION: Use relative API path from env var only — avoid hardcoded absolute URL fallback +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +const ACTION_LABELS = { + cve_create: 'added CVE', + cve_edit: 'edited CVE', + cve_delete: 'deleted CVE', + cve_update_status: 'updated status', + cve_nvd_sync: 'ran NVD sync', + document_upload: 'uploaded doc', + document_delete: 'deleted doc', + user_create: 'added user', + user_update: 'updated user', + user_delete: 'deleted user', + jira_ticket_create: 'created ticket', + jira_ticket_update: 'updated ticket', + jira_ticket_delete: 'deleted ticket', + archer_ticket_create: 'created Archer ticket', + archer_ticket_update: 'updated Archer ticket', + archer_ticket_delete: 'deleted Archer ticket', + ivanti_sync: 'synced Ivanti', + compliance_upload: 'uploaded compliance', + kb_create: 'created KB article', + kb_update: 'updated KB article', +}; + +const ACTION_COLORS = { + cve_create: '#0EA5E9', + cve_edit: '#F59E0B', + cve_delete: '#EF4444', + cve_nvd_sync: '#10B981', + document_upload: '#8B5CF6', + document_delete: '#EF4444', + user_create: '#0EA5E9', + user_update: '#F59E0B', + user_delete: '#EF4444', + jira_ticket_create: '#F59E0B', + ivanti_sync: '#0D9488', + compliance_upload: '#10B981', +}; + +function timeAgo(dateStr) { + const now = new Date(); + const then = new Date(dateStr); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return then.toLocaleDateString(); +} + +function formatActivity(activity) { + const label = ACTION_LABELS[activity.action] || activity.action.replace(/_/g, ' '); + const entity = activity.entity_id || ''; + let detail = ''; + + if (activity.details) { + try { + const d = typeof activity.details === 'string' ? JSON.parse(activity.details) : activity.details; + if (d.vendor) detail = `(${d.vendor})`; + else if (d.cve_id) detail = `(${d.cve_id})`; + else if (d.ticket_key) detail = `(${d.ticket_key})`; + else if (d.exc_number) detail = `(${d.exc_number})`; + else if (d.username) detail = `(${d.username})`; + } catch (_e) { /* ignore parse errors */ } + } + + return { label, entity, detail }; +} + +export default function RecentActivityFeed() { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchActivity = useCallback(async () => { + try { + const response = await fetch(`${API_BASE}/recent-activity?limit=8`, { credentials: 'include' }); + if (!response.ok) return; + const data = await response.json(); + setActivities(data.activities || []); + } catch (_err) { + // Silent fail — this is a non-critical widget + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchActivity(); }, [fetchActivity]); + + // Auto-refresh every 60 seconds + useEffect(() => { + const interval = setInterval(fetchActivity, 60000); + return () => clearInterval(interval); + }, [fetchActivity]); + + return ( +
+
+

+ + Recent Activity +

+ +
+ + {loading ? ( +
+

Loading...

+
+ ) : activities.length === 0 ? ( +
+

No recent activity

+
+ ) : ( +
+ {activities.map((a, idx) => { + const { label, entity, detail } = formatActivity(a); + const color = ACTION_COLORS[a.action] || '#64748B'; + + return ( +
+ +
+
+ {a.username} + {' '}{label} + {entity && {entity}} + {detail && {detail}} +
+
+ {timeAgo(a.created_at)} +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/SidebarTabs.js b/frontend/src/components/SidebarTabs.js new file mode 100644 index 0000000..29d9ce0 --- /dev/null +++ b/frontend/src/components/SidebarTabs.js @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { AlertCircle, Shield, Activity } from 'lucide-react'; + +const TAB_CONFIG = [ + { id: 'tickets', label: 'Tickets', icon: AlertCircle, color: '#F59E0B' }, + { id: 'archer', label: 'Archer', icon: Shield, color: '#8B5CF6' }, + { id: 'ivanti', label: 'Ivanti', icon: Activity, color: '#0D9488' }, +]; + +export default function SidebarTabs({ children }) { + const [activeTab, setActiveTab] = useState('tickets'); + + // children should be an object: { tickets: , archer: , ivanti: } + // Or we accept children as array and map by index + const panels = children; + + return ( +
+ {/* Tab bar */} +
+ {TAB_CONFIG.map(tab => { + const Icon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Tab content */} +
+ {activeTab === 'tickets' && panels.tickets} + {activeTab === 'archer' && panels.archer} + {activeTab === 'ivanti' && panels.ivanti} +
+
+ ); +} diff --git a/frontend/src/components/pages/HomePage.js b/frontend/src/components/pages/HomePage.js index a26613c..ccbd881 100644 --- a/frontend/src/components/pages/HomePage.js +++ b/frontend/src/components/pages/HomePage.js @@ -10,6 +10,8 @@ import CVECard from '../CVECard'; import OpenTicketsPanel from '../OpenTicketsPanel'; import IvantiWorkflowPanel from '../IvantiWorkflowPanel'; import CalendarWidget from '../CalendarWidget'; +import RecentActivityFeed from '../RecentActivityFeed'; +import SidebarTabs from '../SidebarTabs'; import ArcherPage from './ArcherPage'; import ConfirmModal from '../ConfirmModal'; import AddCVEModal from '../modals/AddCVEModal'; @@ -370,26 +372,36 @@ export default function HomePage({ onNavigate, showAddCVE, setShowAddCVE }) { /> - {/* Open Tickets */} - setJiraModal({ context: null })} - onEdit={(ticket) => setJiraModal({ ticket })} - onDelete={handleDeleteTicket} - /> + {/* Recent Activity */} + - {/* Archer Tickets */} - setArcherModal({ ticket })} - onDeleteTicket={handleDeleteArcherTicket} - onFilterByExc={(exc) => onNavigate('triage', { reportingExcFilter: exc })} - onAddTicket={() => setArcherModal({ context: null })} - canDeleteTicket={canDelete} - /> - - {/* Ivanti Workflows */} - + {/* Tabbed panels: Tickets / Archer / Ivanti */} + + {{ + tickets: ( + setJiraModal({ context: null })} + onEdit={(ticket) => setJiraModal({ ticket })} + onDelete={handleDeleteTicket} + embedded + /> + ), + archer: ( + setArcherModal({ ticket })} + onDeleteTicket={handleDeleteArcherTicket} + onFilterByExc={(exc) => onNavigate('triage', { reportingExcFilter: exc })} + onAddTicket={() => setArcherModal({ context: null })} + canDeleteTicket={canDelete} + /> + ), + ivanti: ( + + ), + }} +