Compare commits
3 Commits
5102a2c5b4
...
0d48c109b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d48c109b3 | |||
| 18ad31228e | |||
| 3dcb91a1fc |
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
41
backend/migrations/add_ivanti_counts_history_table.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Migration: Add ivanti_counts_history table
|
||||||
|
//
|
||||||
|
// Stores a snapshot of open/closed Ivanti finding counts on every sync.
|
||||||
|
// Unlike ivanti_counts_cache (single-row, always overwritten), this table
|
||||||
|
// accumulates all snapshots so time-series charts can be built from it.
|
||||||
|
//
|
||||||
|
// The GET /api/ivanti/findings/counts/history endpoint aggregates these rows
|
||||||
|
// to the last snapshot per calendar day using a ROW_NUMBER window function.
|
||||||
|
//
|
||||||
|
// NOTE: This table is also created automatically at server startup via
|
||||||
|
// CREATE TABLE IF NOT EXISTS in initTables() (ivantiFindings.js).
|
||||||
|
// This script is provided for manual setup on fresh installs and for
|
||||||
|
// documentation consistency with other migration files.
|
||||||
|
//
|
||||||
|
// Usage: node backend/migrations/add_ivanti_counts_history_table.js
|
||||||
|
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting ivanti_counts_history migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_counts_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
open_count INTEGER NOT NULL,
|
||||||
|
closed_count INTEGER NOT NULL,
|
||||||
|
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating ivanti_counts_history table:', err);
|
||||||
|
else console.log('✓ ivanti_counts_history table created (or already exists)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete.');
|
||||||
|
});
|
||||||
@@ -6,8 +6,6 @@ import UserMenu from './components/UserMenu';
|
|||||||
import UserManagement from './components/UserManagement';
|
import UserManagement from './components/UserManagement';
|
||||||
import AuditLog from './components/AuditLog';
|
import AuditLog from './components/AuditLog';
|
||||||
import NvdSyncModal from './components/NvdSyncModal';
|
import NvdSyncModal from './components/NvdSyncModal';
|
||||||
import KnowledgeBaseModal from './components/KnowledgeBaseModal';
|
|
||||||
import KnowledgeBaseViewer from './components/KnowledgeBaseViewer';
|
|
||||||
import NavDrawer from './components/NavDrawer';
|
import NavDrawer from './components/NavDrawer';
|
||||||
import CalendarWidget from './components/CalendarWidget';
|
import CalendarWidget from './components/CalendarWidget';
|
||||||
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
import VulnerabilityTriagePage from './components/pages/ReportingPage';
|
||||||
@@ -185,9 +183,6 @@ export default function App() {
|
|||||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
const [showNvdSync, setShowNvdSync] = useState(false);
|
const [showNvdSync, setShowNvdSync] = useState(false);
|
||||||
const [showKnowledgeBase, setShowKnowledgeBase] = useState(false);
|
|
||||||
const [knowledgeBaseArticles, setKnowledgeBaseArticles] = useState([]);
|
|
||||||
const [selectedKBArticle, setSelectedKBArticle] = useState(null);
|
|
||||||
const [newCVE, setNewCVE] = useState({
|
const [newCVE, setNewCVE] = useState({
|
||||||
cve_id: '',
|
cve_id: '',
|
||||||
vendor: '',
|
vendor: '',
|
||||||
@@ -311,19 +306,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchKnowledgeBaseArticles = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/knowledge-base`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch knowledge base articles');
|
|
||||||
const data = await response.json();
|
|
||||||
setKnowledgeBaseArticles(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching knowledge base articles:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchJiraTickets = async () => {
|
const fetchJiraTickets = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/jira-tickets`, {
|
const response = await fetch(`${API_BASE}/jira-tickets`, {
|
||||||
@@ -442,45 +424,6 @@ export default function App() {
|
|||||||
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
|
alert(`Exporting ${selectedDocuments.length} documents for report attachment`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewKBArticle = async (articleId) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/knowledge-base/${articleId}`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch article');
|
|
||||||
|
|
||||||
const article = await response.json();
|
|
||||||
setSelectedKBArticle(article);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching knowledge base article:', err);
|
|
||||||
setError('Failed to load article');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadKBArticle = async (id, filename) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/knowledge-base/${id}/download`, {
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Download failed');
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error downloading knowledge base article:', err);
|
|
||||||
setError('Failed to download document');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCVE = async (e) => {
|
const handleAddCVE = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -916,7 +859,6 @@ export default function App() {
|
|||||||
fetchJiraTickets();
|
fetchJiraTickets();
|
||||||
fetchArcherTickets();
|
fetchArcherTickets();
|
||||||
fetchIvantiWorkflows();
|
fetchIvantiWorkflows();
|
||||||
fetchKnowledgeBaseArticles();
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
@@ -1063,14 +1005,6 @@ export default function App() {
|
|||||||
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Knowledge Base Modal */}
|
|
||||||
{showKnowledgeBase && (
|
|
||||||
<KnowledgeBaseModal
|
|
||||||
onClose={() => setShowKnowledgeBase(false)}
|
|
||||||
onUpdate={fetchKnowledgeBaseArticles}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add CVE Modal */}
|
{/* Add CVE Modal */}
|
||||||
{showAddCVE && (
|
{showAddCVE && (
|
||||||
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 modal-overlay flex items-center justify-center z-50 p-4">
|
||||||
@@ -1661,90 +1595,11 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Three Column Layout - Home page only */}
|
{/* Two Column Layout - Home page only */}
|
||||||
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
|
{currentPage === 'home' && <div className="grid grid-cols-12 gap-6">
|
||||||
{/* LEFT PANEL - Wiki/Knowledge Base */}
|
|
||||||
<div className="col-span-12 lg:col-span-3 space-y-4">
|
|
||||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #10B981'}} className="rounded-lg">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#10B981', marginBottom: '0', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(16, 185, 129, 0.4)' }}>
|
|
||||||
Knowledge Base
|
|
||||||
</h2>
|
|
||||||
{(user?.role === 'admin' || user?.role === 'editor') && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowKnowledgeBase(true)}
|
|
||||||
className="intel-button intel-button-small"
|
|
||||||
style={{ fontSize: '0.75rem', padding: '0.375rem 0.75rem' }}
|
|
||||||
title="Manage Knowledge Base"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Knowledge Base Entries */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{knowledgeBaseArticles.length === 0 ? (
|
|
||||||
<div className="text-center py-8" style={{ color: '#64748B' }}>
|
|
||||||
<FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
||||||
<p className="text-sm">No documents yet</p>
|
|
||||||
{(user?.role === 'admin' || user?.role === 'editor') && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowKnowledgeBase(true)}
|
|
||||||
className="intel-button intel-button-small mt-3"
|
|
||||||
>
|
|
||||||
Add First Document
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
knowledgeBaseArticles.slice(0, 5).map((article) => (
|
|
||||||
<div
|
|
||||||
key={article.id}
|
|
||||||
onClick={() => handleViewKBArticle(article.id)}
|
|
||||||
style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(16, 185, 129, 0.25)', borderRadius: '0.375rem', padding: '0.75rem', cursor: 'pointer', transition: 'all 0.2s' }}
|
|
||||||
className="hover:border-intel-success"
|
|
||||||
>
|
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 font-mono">{article.title}</h3>
|
|
||||||
{article.description && (
|
|
||||||
<p className="text-gray-400 text-xs mb-2 line-clamp-2">{article.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-intel-success font-mono">
|
|
||||||
{new Date(article.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
{article.category && article.category !== 'General' && (
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded" style={{ background: 'rgba(16, 185, 129, 0.2)', color: '#10B981' }}>
|
|
||||||
{article.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{knowledgeBaseArticles.length > 5 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowKnowledgeBase(true)}
|
|
||||||
className="text-xs text-center w-full py-2"
|
|
||||||
style={{ color: '#10B981' }}
|
|
||||||
>
|
|
||||||
View all {knowledgeBaseArticles.length} documents →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CENTER PANEL - Main Content */}
|
{/* CENTER PANEL - Main Content */}
|
||||||
<div className="col-span-12 lg:col-span-6 space-y-4">
|
<div className="col-span-12 lg:col-span-9 space-y-4">
|
||||||
{/* Knowledge Base Viewer */}
|
<>
|
||||||
{selectedKBArticle ? (
|
|
||||||
<KnowledgeBaseViewer
|
|
||||||
article={selectedKBArticle}
|
|
||||||
onClose={() => setSelectedKBArticle(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Quick Check */}
|
{/* Quick Check */}
|
||||||
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
||||||
<div className="scan-line"></div>
|
<div className="scan-line"></div>
|
||||||
@@ -2216,7 +2071,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* End Center Panel */}
|
{/* End Center Panel */}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,484 @@
|
|||||||
import React from 'react';
|
// KnowledgeBasePage.js
|
||||||
import { BookOpen } from 'lucide-react';
|
// Full-page knowledge base library — browse, search, filter, and read
|
||||||
|
// articles inline. Upload and delete require editor/admin role.
|
||||||
|
// Reuses existing KnowledgeBaseViewer and KnowledgeBaseModal components.
|
||||||
|
|
||||||
export default function KnowledgeBasePage() {
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
return (
|
import {
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '60vh' }}>
|
BookOpen, Search, Upload, RefreshCw, Loader,
|
||||||
<div style={{ textAlign: 'center' }}>
|
AlertCircle, FileText, File, Trash2, X,
|
||||||
<div style={{
|
} from 'lucide-react';
|
||||||
width: '72px', height: '72px', borderRadius: '1rem', margin: '0 auto 1.5rem',
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
background: 'rgba(16, 185, 129, 0.1)',
|
import KnowledgeBaseModal from '../KnowledgeBaseModal';
|
||||||
border: '1px solid rgba(16, 185, 129, 0.3)',
|
import KnowledgeBaseViewer from '../KnowledgeBaseViewer';
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
|
||||||
}}>
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
<BookOpen style={{ width: '36px', height: '36px', color: '#10B981' }} />
|
const GREEN = '#10B981';
|
||||||
</div>
|
|
||||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700', color: '#10B981', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>
|
// ---------------------------------------------------------------------------
|
||||||
Knowledge Base
|
// Static config
|
||||||
</h2>
|
// ---------------------------------------------------------------------------
|
||||||
<p style={{ color: '#475569', fontSize: '0.875rem', fontFamily: 'monospace' }}>
|
const CATEGORY_COLORS = {
|
||||||
Under construction — coming soon
|
General: '#94A3B8',
|
||||||
</p>
|
Policy: '#0EA5E9',
|
||||||
</div>
|
Procedure: GREEN,
|
||||||
</div>
|
Guide: '#F59E0B',
|
||||||
);
|
Reference: '#8B5CF6',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILE_EXT_COLORS = {
|
||||||
|
pdf: '#EF4444',
|
||||||
|
md: '#10B981',
|
||||||
|
txt: '#94A3B8',
|
||||||
|
doc: '#0EA5E9',
|
||||||
|
docx: '#0EA5E9',
|
||||||
|
xls: '#10B981',
|
||||||
|
xlsx: '#10B981',
|
||||||
|
ppt: '#F97316',
|
||||||
|
pptx: '#F97316',
|
||||||
|
html: '#8B5CF6',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = ['Procedure', 'Guide', 'Policy', 'Reference', 'General'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extOf(filename) {
|
||||||
|
return (filename || '').split('.').pop().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extColor(filename) {
|
||||||
|
return FILE_EXT_COLORS[extOf(filename)] || '#64748B';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(bytes) {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return new Date(str).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function catColor(cat) {
|
||||||
|
return CATEGORY_COLORS[cat] || '#94A3B8';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ArticleCard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ArticleCard({ article, selected, onSelect, onDelete, canDelete }) {
|
||||||
|
const color = catColor(article.category);
|
||||||
|
const fileColor = extColor(article.file_name);
|
||||||
|
const ext = extOf(article.file_name).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onSelect(article)}
|
||||||
|
style={{
|
||||||
|
background: selected
|
||||||
|
? `linear-gradient(135deg,rgba(16,185,129,0.1) 0%,rgba(15,23,42,0.98) 100%)`
|
||||||
|
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||||
|
border: `1.5px solid ${selected ? GREEN : 'rgba(16,185,129,0.12)'}`,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.35)'; }}
|
||||||
|
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.12)'; }}
|
||||||
|
>
|
||||||
|
{/* File type badge + delete button */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700',
|
||||||
|
color: fileColor, padding: '0.15rem 0.4rem',
|
||||||
|
background: `${fileColor}15`, borderRadius: '0.2rem',
|
||||||
|
border: `1px solid ${fileColor}30`,
|
||||||
|
}}>
|
||||||
|
{ext}
|
||||||
|
</span>
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete(article); }}
|
||||||
|
title="Delete article"
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: '#334155', padding: '0.15rem',
|
||||||
|
borderRadius: '0.2rem', display: 'flex', alignItems: 'center',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; }}
|
||||||
|
>
|
||||||
|
<Trash2 style={{ width: '12px', height: '12px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.82rem', fontWeight: '700',
|
||||||
|
color: selected ? GREEN : '#E2E8F0',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}>
|
||||||
|
{article.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{article.description && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.7rem', color: '#475569',
|
||||||
|
lineHeight: 1.45, display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{article.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer — category + date */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
|
||||||
|
color, padding: '0.15rem 0.4rem',
|
||||||
|
background: `${color}12`, borderRadius: '0.2rem',
|
||||||
|
border: `1px solid ${color}25`,
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||||
|
}}>
|
||||||
|
{article.category}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
{article.file_size && (
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
|
||||||
|
{fmtSize(article.file_size)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
|
||||||
|
{fmtDate(article.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function EmptyState({ hasFilter, onClear }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
gridColumn: '1 / -1',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
justifyContent: 'center', padding: '4rem 2rem',
|
||||||
|
border: '1px dashed rgba(16,185,129,0.15)', borderRadius: '0.5rem',
|
||||||
|
color: '#334155',
|
||||||
|
}}>
|
||||||
|
<BookOpen style={{ width: '36px', height: '36px', marginBottom: '1rem', opacity: 0.4 }} />
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', marginBottom: '0.375rem' }}>
|
||||||
|
{hasFilter ? 'No articles match your search' : 'No articles yet'}
|
||||||
|
</div>
|
||||||
|
{hasFilter ? (
|
||||||
|
<button onClick={onClear} style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: GREEN, fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
marginTop: '0.375rem',
|
||||||
|
}}>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
||||||
|
Upload a document to get started
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function KnowledgeBasePage() {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
|
|
||||||
|
const [articles, setArticles] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeCategory, setActiveCategory] = useState('All');
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Fetch
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const fetchArticles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/knowledge-base`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error('Failed to load articles');
|
||||||
|
const data = await res.json();
|
||||||
|
setArticles(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchArticles(); }, [fetchArticles]);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Delete
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const handleDelete = useCallback(async (article) => {
|
||||||
|
if (!window.confirm(`Delete "${article.title}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/knowledge-base/${article.id}`, {
|
||||||
|
method: 'DELETE', credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Delete failed');
|
||||||
|
setArticles(prev => prev.filter(a => a.id !== article.id));
|
||||||
|
if (selected?.id === article.id) setSelected(null);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Failed to delete: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Filtering
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return articles.filter(a => {
|
||||||
|
const matchesCat = activeCategory === 'All' || a.category === activeCategory;
|
||||||
|
const matchesSearch = !q ||
|
||||||
|
a.title.toLowerCase().includes(q) ||
|
||||||
|
(a.description || '').toLowerCase().includes(q);
|
||||||
|
return matchesCat && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [articles, activeCategory, search]);
|
||||||
|
|
||||||
|
// Category tab counts (always from full list, not filtered by search)
|
||||||
|
const categoryCounts = useMemo(() => {
|
||||||
|
const counts = { All: articles.length };
|
||||||
|
CATEGORY_ORDER.forEach(cat => {
|
||||||
|
counts[cat] = articles.filter(a => a.category === cat).length;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [articles]);
|
||||||
|
|
||||||
|
const activeTabs = ['All', ...CATEGORY_ORDER.filter(c => categoryCounts[c] > 0)];
|
||||||
|
|
||||||
|
const clearFilters = () => { setSearch(''); setActiveCategory('All'); };
|
||||||
|
|
||||||
|
const hasFilter = search.trim() !== '' || activeCategory !== 'All';
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem', paddingBottom: '2rem' }}>
|
||||||
|
|
||||||
|
{/* ── Page header ─────────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
|
||||||
|
color: GREEN, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||||
|
textShadow: `0 0 16px ${GREEN}40`, marginBottom: '0.25rem',
|
||||||
|
}}>
|
||||||
|
Knowledge Base
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||||
|
{loading ? '…' : `${articles.length} article${articles.length !== 1 ? 's' : ''}`}
|
||||||
|
{articles.length > 0 && activeCategory !== 'All' && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', color: '#334155' }}>
|
||||||
|
· {categoryCounts[activeCategory] || 0} in {activeCategory}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={fetchArticles}
|
||||||
|
title="Refresh"
|
||||||
|
style={{
|
||||||
|
background: 'none', border: `1px solid rgba(16,185,129,0.25)`,
|
||||||
|
borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = GREEN; e.currentTarget.style.borderColor = `${GREEN}60`; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(16,185,129,0.25)'; }}
|
||||||
|
>
|
||||||
|
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||||
|
</button>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpload(true)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||||
|
background: `${GREEN}18`, border: `1px solid ${GREEN}`,
|
||||||
|
color: GREEN, padding: '0.5rem 1rem',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
cursor: 'pointer', borderRadius: '0.375rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload style={{ width: '14px', height: '14px' }} />
|
||||||
|
Upload Article
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Search + category tabs ───────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<Search style={{
|
||||||
|
position: 'absolute', left: '0.625rem', top: '50%', transform: 'translateY(-50%)',
|
||||||
|
width: '13px', height: '13px', color: '#334155', pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search articles…"
|
||||||
|
style={{
|
||||||
|
paddingLeft: '2rem', paddingRight: search ? '2rem' : '0.625rem',
|
||||||
|
paddingTop: '0.4rem', paddingBottom: '0.4rem',
|
||||||
|
background: 'rgba(15,23,42,0.8)',
|
||||||
|
border: '1px solid rgba(16,185,129,0.2)',
|
||||||
|
borderRadius: '0.375rem', color: '#E2E8F0',
|
||||||
|
outline: 'none', fontFamily: 'monospace', fontSize: '0.75rem',
|
||||||
|
width: '220px',
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = `${GREEN}60`}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'rgba(16,185,129,0.2)'}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X style={{ width: '12px', height: '12px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
|
||||||
|
{activeTabs.map(cat => {
|
||||||
|
const isActive = activeCategory === cat;
|
||||||
|
const color = cat === 'All' ? GREEN : catColor(cat);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setActiveCategory(cat)}
|
||||||
|
style={{
|
||||||
|
padding: '0.35rem 0.75rem',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
cursor: 'pointer', borderRadius: '0.25rem',
|
||||||
|
border: isActive ? `1px solid ${color}` : '1px solid transparent',
|
||||||
|
background: isActive ? `${color}15` : 'transparent',
|
||||||
|
color: isActive ? color : '#475569',
|
||||||
|
transition: 'all 0.12s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }}}
|
||||||
|
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'transparent'; }}}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
<span style={{ marginLeft: '0.35rem', opacity: 0.6, fontWeight: '400' }}>
|
||||||
|
{categoryCounts[cat] ?? 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Error state ──────────────────────────────────────────── */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
|
padding: '0.875rem 1rem',
|
||||||
|
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
borderRadius: '0.5rem', color: '#F87171',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.78rem',
|
||||||
|
}}>
|
||||||
|
<AlertCircle style={{ width: '15px', height: '15px', flexShrink: 0 }} />
|
||||||
|
{error}
|
||||||
|
<button
|
||||||
|
onClick={fetchArticles}
|
||||||
|
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#F87171', fontFamily: 'monospace', fontSize: '0.72rem' }}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Loading state ────────────────────────────────────────── */}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||||
|
<Loader style={{ width: '28px', height: '28px', color: GREEN, animation: 'spin 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Article grid ─────────────────────────────────────────── */}
|
||||||
|
{!loading && !error && (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||||
|
gap: '0.875rem',
|
||||||
|
}}>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState hasFilter={hasFilter} onClear={clearFilters} />
|
||||||
|
) : (
|
||||||
|
filtered.map(article => (
|
||||||
|
<ArticleCard
|
||||||
|
key={article.id}
|
||||||
|
article={article}
|
||||||
|
selected={selected?.id === article.id}
|
||||||
|
onSelect={a => setSelected(selected?.id === a.id ? null : a)}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
canDelete={canWrite()}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Inline viewer ────────────────────────────────────────── */}
|
||||||
|
{selected && (
|
||||||
|
<div style={{ marginTop: '0.25rem' }}>
|
||||||
|
<KnowledgeBaseViewer
|
||||||
|
article={selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Upload modal ─────────────────────────────────────────── */}
|
||||||
|
{showUpload && (
|
||||||
|
<KnowledgeBaseModal
|
||||||
|
onClose={() => setShowUpload(false)}
|
||||||
|
onUpdate={() => { fetchArticles(); setShowUpload(false); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user