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:
@@ -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());
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
158
frontend/src/components/RecentActivityFeed.js
Normal file
158
frontend/src/components/RecentActivityFeed.js
Normal 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>
|
||||
);
|
||||
}
|
||||
52
frontend/src/components/SidebarTabs.js
Normal file
52
frontend/src/components/SidebarTabs.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user