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.
159 lines
5.4 KiB
JavaScript
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>
|
|
);
|
|
}
|