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.
This commit is contained in:
Jordan Ramos
2026-06-23 12:16:40 -06:00
parent 306950e360
commit f119cca1d7
6 changed files with 274 additions and 27 deletions

View File

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

View File

@@ -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 (
<div className="panel-card panel-card--teal">
const content = (
<>
<div className="flex justify-between items-center mb-1">
<h2 className="section-heading section-heading--teal">
<Activity className="w-5 h-5" />
@@ -219,6 +219,9 @@ export default function IvantiWorkflowPanel() {
</div>
</>
)}
</div>
</>
);
if (embedded) return content;
return <div className="panel-card panel-card--teal">{content}</div>;
}

View File

@@ -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 (
<div className="panel-card panel-card--warning">
const content = (
<>
<div className="flex justify-between items-center mb-4">
<h2 className="section-heading section-heading--warning">
<AlertCircle className="w-5 h-5" />
@@ -86,6 +86,9 @@ export default function OpenTicketsPanel({ tickets, onAdd, onEdit, onDelete }) {
</div>
)}
</div>
</div>
</>
);
if (embedded) return content;
return <div className="panel-card panel-card--warning">{content}</div>;
}

View File

@@ -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 (
<div className="panel-card panel-card--accent">
<div className="flex justify-between items-center mb-3">
<h2 className="section-heading section-heading--accent">
<Activity className="w-4 h-4" />
Recent Activity
</h2>
<button
onClick={fetchActivity}
className="text-gray-500 hover:text-intel-accent transition-colors"
title="Refresh"
aria-label="Refresh activity feed"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
{loading ? (
<div className="text-center py-4">
<p className="text-xs text-gray-500 font-mono">Loading...</p>
</div>
) : activities.length === 0 ? (
<div className="text-center py-4">
<p className="text-xs text-gray-500 font-mono">No recent activity</p>
</div>
) : (
<div className="space-y-1.5">
{activities.map((a, idx) => {
const { label, entity, detail } = formatActivity(a);
const color = ACTION_COLORS[a.action] || '#64748B';
return (
<div key={idx} className="flex items-start gap-2 py-1.5" style={{ borderBottom: '1px solid rgba(100, 116, 139, 0.15)' }}>
<span
className="glow-dot flex-shrink-0 mt-1.5"
style={{ width: '6px', height: '6px', background: color, boxShadow: `0 0 6px ${color}` }}
></span>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-300 truncate">
<span className="text-white font-medium">{a.username}</span>
{' '}{label}
{entity && <span className="text-intel-accent font-mono"> {entity}</span>}
{detail && <span className="text-gray-400"> {detail}</span>}
</div>
<div className="text-xs text-gray-500 font-mono" style={{ fontSize: '0.6rem' }}>
{timeAgo(a.created_at)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -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: <Node>, archer: <Node>, ivanti: <Node> }
// Or we accept children as array and map by index
const panels = children;
return (
<div className="panel-card" style={{ padding: 0, overflow: 'hidden' }}>
{/* Tab bar */}
<div className="flex" style={{ borderBottom: '1px solid rgba(100, 116, 139, 0.25)' }}>
{TAB_CONFIG.map(tab => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 px-2 transition-all font-mono text-xs uppercase tracking-wider"
style={{
color: isActive ? tab.color : '#64748B',
background: isActive ? `rgba(${tab.color === '#F59E0B' ? '245,158,11' : tab.color === '#8B5CF6' ? '139,92,246' : '13,148,136'}, 0.08)` : 'transparent',
borderBottom: isActive ? `2px solid ${tab.color}` : '2px solid transparent',
}}
aria-selected={isActive}
role="tab"
>
<Icon className="w-3.5 h-3.5" />
<span>{tab.label}</span>
</button>
);
})}
</div>
{/* Tab content */}
<div style={{ padding: '1rem' }}>
{activeTab === 'tickets' && panels.tickets}
{activeTab === 'archer' && panels.archer}
{activeTab === 'ivanti' && panels.ivanti}
</div>
</div>
);
}

View File

@@ -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 }) {
/>
</div>
{/* Open Tickets */}
<OpenTicketsPanel
tickets={jiraTickets}
onAdd={() => setJiraModal({ context: null })}
onEdit={(ticket) => setJiraModal({ ticket })}
onDelete={handleDeleteTicket}
/>
{/* Recent Activity */}
<RecentActivityFeed />
{/* Archer Tickets */}
<ArcherPage
archerTickets={archerTickets}
onEditTicket={(ticket) => setArcherModal({ ticket })}
onDeleteTicket={handleDeleteArcherTicket}
onFilterByExc={(exc) => onNavigate('triage', { reportingExcFilter: exc })}
onAddTicket={() => setArcherModal({ context: null })}
canDeleteTicket={canDelete}
/>
{/* Ivanti Workflows */}
<IvantiWorkflowPanel />
{/* Tabbed panels: Tickets / Archer / Ivanti */}
<SidebarTabs>
{{
tickets: (
<OpenTicketsPanel
tickets={jiraTickets}
onAdd={() => setJiraModal({ context: null })}
onEdit={(ticket) => setJiraModal({ ticket })}
onDelete={handleDeleteTicket}
embedded
/>
),
archer: (
<ArcherPage
archerTickets={archerTickets}
onEditTicket={(ticket) => setArcherModal({ ticket })}
onDeleteTicket={handleDeleteArcherTicket}
onFilterByExc={(exc) => onNavigate('triage', { reportingExcFilter: exc })}
onAddTicket={() => setArcherModal({ context: null })}
canDeleteTicket={canDelete}
/>
),
ivanti: (
<IvantiWorkflowPanel embedded />
),
}}
</SidebarTabs>
</div>
</div>