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 ? (
+
+ ) : activities.length === 0 ? (
+
+ ) : (
+
+ {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: (
+
+ ),
+ }}
+