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