Files
cve-dashboard/frontend/src/components/RecentActivityFeed.js
Jordan Ramos f119cca1d7 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.
2026-06-23 12:16:40 -06:00

159 lines
5.4 KiB
JavaScript

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