| <!DOCTYPE html> |
| <html lang="en" data-theme="light"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Murshid | مُرشِد</title> |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>" /> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script> |
| <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script> |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> |
| <script src="https://unpkg.com/axios/dist/axios.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> |
| <style> |
| :root { --accent: #3B82F6; } |
| [data-theme="dark"] { color-scheme: dark; } |
| [data-theme="dark"] body { background: #0f172a; color: #e2e8f0; } |
| [data-theme="dark"] .card { background: #1e293b; border-color: #334155; } |
| [data-theme="dark"] .sidebar { background: #1e293b; border-color: #334155; } |
| [data-theme="dark"] .topbar { background: #1e293b; border-color: #334155; } |
| [data-theme="dark"] input, [data-theme="dark"] textarea, [data-theme="dark"] select { |
| background: #0f172a; border-color: #334155; color: #e2e8f0; |
| } |
| [data-theme="dark"] table tr:hover td { background: #1e3a5f; } |
| .theme-blue { --accent: #3B82F6; } |
| .theme-purple { --accent: #8B5CF6; } |
| .theme-green { --accent: #10B981; } |
| .theme-orange { --accent: #F59E0B; } |
| .accent-btn { background-color: var(--accent); } |
| .accent-text { color: var(--accent); } |
| .accent-border { border-color: var(--accent); } |
| .sidebar-link.active { background: var(--accent); color: white; } |
| .sidebar-link { transition: all .2s; } |
| .sidebar-link:not(.active):hover { background: rgba(var(--accent-rgb,59,130,246),.12); } |
| .card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; } |
| .spinner { border: 3px solid #e2e8f0; border-top-color: var(--accent); border-radius: 50%; width: 32px; height: 32px; animation: spin .8s linear infinite; } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .fade-in { animation: fadeIn .3s ease; } |
| @keyframes fadeIn { from { opacity:0; transform: translateY(8px); } to { opacity:1; transform:none; } } |
| .tag-high { background: #d1fae5; color: #065f46; } |
| .tag-medium { background: #fef3c7; color: #92400e; } |
| .tag-low { background: #fee2e2; color: #991b1b; } |
| [data-theme="dark"] .tag-high { background: #064e3b; color: #6ee7b7; } |
| [data-theme="dark"] .tag-medium { background: #451a03; color: #fcd34d; } |
| [data-theme="dark"] .tag-low { background: #450a0a; color: #fca5a5; } |
| .wql-block { font-family: 'Courier New', monospace; font-size: 12px; white-space: pre-wrap; } |
| .copy-btn { transition: all .15s; } |
| .copy-btn:hover { transform: scale(1.05); } |
| pre { overflow-x: auto; } |
| </style> |
| <script> |
| tailwind.config = { |
| darkMode: 'class', |
| theme: { extend: { colors: { accent: 'var(--accent)' } } } |
| } |
| </script> |
| </head> |
| <body class="bg-gray-50 min-h-screen font-sans"> |
| <div id="root"></div> |
|
|
| <script type="text/babel"> |
| const { useState, useEffect, useRef, useCallback } = React; |
| |
| |
| |
| const BASE = window.location.origin; |
| const api = axios.create({ baseURL: BASE }); |
| |
| const getHealth = () => api.get('/health'); |
| const getStats = () => api.get('/api/stats'); |
| const analyzeRule = (rule_xml) => api.post('/rules/analyze', { rule_xml }); |
| const getResults = (rule_id) => api.get(`/results/${rule_id}`); |
| const getQueries = (technique_id) => api.get(`/queries/${technique_id}`); |
| const addTemplate = (data) => api.post('/admin/templates', data); |
| const updateTemplate = (id, data) => api.patch(`/admin/templates/${id}`, data); |
| |
| const getDbSummary = () => api.get('/api/db/summary'); |
| const getDbRules = () => api.get('/api/db/rules'); |
| const getDbMappings = () => api.get('/api/db/mappings'); |
| const getDbTemplates = () => api.get('/api/db/templates'); |
| const getDbTechniques = () => api.get('/api/db/techniques'); |
| |
| |
| const store = { |
| get: (k, def) => { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : def; } catch { return def; } }, |
| set: (k, v) => localStorage.setItem(k, JSON.stringify(v)), |
| }; |
| |
| |
| const ICONS = { |
| dashboard: '🏠', |
| rules: '🔍', |
| admin: '⚙️', |
| templates: '📋', |
| settings: '👤', |
| }; |
| |
| |
| function StatsCard({ icon, label, value, sub, color }) { |
| return ( |
| <div className="card p-5 flex items-start gap-4 fade-in"> |
| <div className={`text-3xl p-2 rounded-xl ${color}`}>{icon}</div> |
| <div> |
| <p className="text-xs text-gray-500 font-medium uppercase tracking-wide">{label}</p> |
| <p className="text-2xl font-bold">{value}</p> |
| {sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>} |
| </div> |
| </div> |
| ); |
| } |
| |
| |
| function TechFreqChart({ data }) { |
| const ref = useRef(null); |
| const chart = useRef(null); |
| useEffect(() => { |
| if (!data?.length || !ref.current) return; |
| chart.current?.destroy(); |
| const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3B82F6'; |
| chart.current = new Chart(ref.current, { |
| type: 'bar', |
| data: { |
| labels: data.map(d => d.technique_id), |
| datasets: [{ label: 'Rules Mapped', data: data.map(d => d.count), |
| backgroundColor: accent + 'cc', borderColor: accent, borderWidth: 1, borderRadius: 6 }] |
| }, |
| options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }, responsive: true, maintainAspectRatio: false } |
| }); |
| return () => chart.current?.destroy(); |
| }, [data]); |
| if (!data?.length) return <p className="text-gray-400 text-sm py-8 text-center">No data yet — analyze rules first.</p>; |
| return <div style={{ height: 220 }}><canvas ref={ref} /></div>; |
| } |
| |
| |
| |
| |
| function TechniqueDistributionChart({ mappings }) { |
| const ref = useRef(null); |
| const chart = useRef(null); |
| |
| useEffect(() => { |
| if (!mappings?.length || !ref.current) return; |
| chart.current?.destroy(); |
| |
| |
| const top5 = mappings.slice(0, 5); |
| |
| const getColor = (pct) => { |
| if (pct >= 70) return { bg: 'rgba(16,185,129,0.8)', border: '#059669' }; |
| if (pct >= 40) return { bg: 'rgba(245,158,11,0.8)', border: '#d97706' }; |
| return { bg: 'rgba(239,68,68,0.8)', border: '#dc2626' }; |
| }; |
| |
| const bgColors = top5.map(m => getColor(m.confidence_percent).bg); |
| const borderColors = top5.map(m => getColor(m.confidence_percent).border); |
| |
| chart.current = new Chart(ref.current, { |
| type: 'bar', |
| data: { |
| labels: top5.map(m => m.technique_id), |
| datasets: [{ |
| label: 'Confidence %', |
| data: top5.map(m => m.confidence_percent), |
| backgroundColor: bgColors, |
| borderColor: borderColors, |
| borderWidth: 1.5, |
| borderRadius: 6, |
| }] |
| }, |
| options: { |
| indexAxis: 'y', |
| responsive: true, |
| maintainAspectRatio: false, |
| plugins: { |
| legend: { display: false }, |
| tooltip: { |
| callbacks: { |
| label: ctx => { |
| const pct = ctx.raw; |
| const level = pct >= 70 ? '🟢 High' : pct >= 40 ? '🟡 Medium' : '🔴 Low'; |
| return ` ${level} ${pct}%`; |
| } |
| } |
| } |
| }, |
| scales: { |
| x: { beginAtZero: true, max: 100, ticks: { callback: v => v + '%' } }, |
| y: { ticks: { font: { family: 'monospace', weight: 'bold' } } } |
| } |
| } |
| }); |
| return () => chart.current?.destroy(); |
| }, [mappings]); |
| |
| if (!mappings?.length) return <p className="text-gray-400 text-sm py-8 text-center">No data.</p>; |
| return <div style={{ height: Math.max(mappings.slice(0,5).length * 50 + 40, 160) }}><canvas ref={ref} /></div>; |
| } |
| |
| |
| function ConfBadge({ pct }) { |
| const cls = pct >= 70 ? 'tag-high' : pct >= 40 ? 'tag-medium' : 'tag-low'; |
| const label = pct >= 70 ? 'High' : pct >= 40 ? 'Med' : 'Low'; |
| return <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${cls}`}>{label} {pct}%</span>; |
| } |
| |
| |
| function Toast({ msg, type, onClose }) { |
| useEffect(() => { const t = setTimeout(onClose, 3000); return () => clearTimeout(t); }, []); |
| const bg = type === 'success' ? 'bg-green-600' : type === 'error' ? 'bg-red-600' : 'bg-blue-600'; |
| return ( |
| <div className={`fixed bottom-5 right-5 z-50 flex items-center gap-3 px-5 py-3 rounded-xl text-white shadow-xl ${bg} fade-in`}> |
| <span>{msg}</span> |
| <button onClick={onClose} className="text-white/70 hover:text-white text-lg leading-none">×</button> |
| </div> |
| ); |
| } |
| |
| |
| function LoginPage({ onLogin }) { |
| const [email, setEmail] = useState('admin@murshid.com'); |
| const [pass, setPass] = useState('password'); |
| const [role, setRole] = useState('analyst'); |
| const [err, setErr] = useState(''); |
| const [loading, setLoading] = useState(false); |
| |
| async function handleSubmit(e) { |
| e.preventDefault(); |
| if (!email || !pass) { setErr('Please fill all fields.'); return; } |
| setLoading(true); |
| try { |
| await getHealth(); |
| onLogin({ email, role, username: email.split('@')[0] }); |
| } catch { |
| setErr('Cannot connect to backend. Make sure the server is running on port 8000.'); |
| } finally { setLoading(false); } |
| } |
| |
| return ( |
| <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900 p-4"> |
| <div className="w-full max-w-md"> |
| <div className="text-center mb-8"> |
| <div className="text-5xl mb-3">🛡️</div> |
| <h1 className="text-3xl font-bold text-white">Murshid | مُرشِد</h1> |
| <p className="text-blue-300 text-sm mt-1">From Alerts to Guidance</p> |
| </div> |
| <div className="bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-8 shadow-2xl"> |
| <h2 className="text-white text-xl font-semibold mb-6 text-center">Sign In</h2> |
| <form onSubmit={handleSubmit} className="space-y-4"> |
| <div> |
| <label className="block text-blue-200 text-sm mb-1.5 font-medium">Email</label> |
| <input className="w-full rounded-xl px-4 py-2.5 bg-white/10 border border-white/20 text-white placeholder-white/40 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400" |
| type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="analyst@soc.com" /> |
| </div> |
| <div> |
| <label className="block text-blue-200 text-sm mb-1.5 font-medium">Password</label> |
| <input className="w-full rounded-xl px-4 py-2.5 bg-white/10 border border-white/20 text-white placeholder-white/40 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400" |
| type="password" value={pass} onChange={e => setPass(e.target.value)} placeholder="••••••••" /> |
| </div> |
| <div> |
| <label className="block text-blue-200 text-sm mb-1.5 font-medium">Role</label> |
| <select className="w-full rounded-xl px-4 py-2.5 bg-white/10 border border-white/20 text-white focus:outline-none focus:border-blue-400" |
| value={role} onChange={e => setRole(e.target.value)}> |
| <option value="analyst" className="bg-slate-800">SOC Analyst</option> |
| <option value="admin" className="bg-slate-800">Administrator</option> |
| </select> |
| </div> |
| {err && <p className="text-red-400 text-sm bg-red-900/30 px-3 py-2 rounded-lg">{err}</p>} |
| <button type="submit" disabled={loading} |
| className="w-full accent-btn text-white font-semibold py-3 rounded-xl hover:opacity-90 transition flex items-center justify-center gap-2 mt-2"> |
| {loading ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Connecting...</> : 'Sign In →'} |
| </button> |
| </form> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
| |
| |
| function Sidebar({ page, onNav, user, onLogout }) { |
| const links = [ |
| { id: 'dashboard', label: 'Dashboard', icon: '🏠' }, |
| { id: 'rules', label: 'Rule Lookup', icon: '🔍' }, |
| { id: 'dbviewer', label: 'نتائج DB', icon: '🗄️' }, |
| ...(user?.role === 'admin' ? [ |
| { id: 'admin', label: 'Rule Mapping', icon: '⚙️' }, |
| { id: 'templates',label: 'WQL Templates',icon: '📋' }, |
| ] : []), |
| { id: 'settings', label: 'Settings', icon: '👤' }, |
| ]; |
| return ( |
| <aside className="sidebar w-60 min-h-screen flex flex-col border-r shadow-sm"> |
| <div className="p-5 border-b"> |
| <div className="flex items-center gap-2.5"> |
| <span className="text-2xl">🛡️</span> |
| <div> |
| <p className="font-bold text-sm">Murshid</p> |
| <p className="text-xs text-gray-500">مُرشِد</p> |
| </div> |
| </div> |
| </div> |
| <nav className="flex-1 p-3 space-y-1"> |
| {links.map(l => ( |
| <button key={l.id} onClick={() => onNav(l.id)} |
| className={`sidebar-link w-full text-left px-3 py-2.5 rounded-lg flex items-center gap-3 text-sm font-medium ${page === l.id ? 'active' : 'text-gray-600'}`}> |
| <span>{l.icon}</span> {l.label} |
| </button> |
| ))} |
| </nav> |
| <div className="p-4 border-t"> |
| <div className="flex items-center gap-3 mb-3"> |
| <div className="w-8 h-8 rounded-full accent-btn flex items-center justify-center text-white text-sm font-bold"> |
| {user?.username?.[0]?.toUpperCase() || 'U'} |
| </div> |
| <div className="overflow-hidden"> |
| <p className="text-sm font-medium truncate">{user?.username}</p> |
| <p className="text-xs text-gray-400 capitalize">{user?.role}</p> |
| </div> |
| </div> |
| <button onClick={onLogout} className="text-xs text-gray-400 hover:text-red-500 flex items-center gap-1 transition"> |
| 🚪 Sign out |
| </button> |
| </div> |
| </aside> |
| ); |
| } |
| |
| |
| function Header({ title, backendOk }) { |
| return ( |
| <header className="topbar border-b px-6 py-4 flex items-center justify-between"> |
| <h1 className="text-lg font-semibold">{title}</h1> |
| <div className="flex items-center gap-2"> |
| <span className={`w-2 h-2 rounded-full ${backendOk ? 'bg-green-500' : 'bg-red-500'}`}></span> |
| <span className="text-xs text-gray-500">Backend {backendOk ? 'online' : 'offline'}</span> |
| </div> |
| </header> |
| ); |
| } |
| |
| |
| function DashboardPage({ user }) { |
| const [stats, setStats] = useState(null); |
| const [loading, setLoading] = useState(true); |
| |
| useEffect(() => { |
| getStats().then(r => setStats(r.data)).catch(() => {}).finally(() => setLoading(false)); |
| }, []); |
| |
| if (loading) return <div className="flex justify-center mt-20"><div className="spinner"></div></div>; |
| |
| return ( |
| <div className="p-6 space-y-6 fade-in"> |
| <div> |
| <h2 className="text-xl font-bold">Welcome, {user?.username} 👋</h2> |
| <p className="text-gray-500 text-sm mt-0.5">Here's a real-time overview of Murshid's analytical performance.</p> |
| </div> |
| <div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> |
| <StatsCard icon="📌" label="Rules Mapped" value={stats?.total_rules_mapped ?? 0} color="bg-blue-50" /> |
| <StatsCard icon="🔗" label="Total Mappings" value={stats?.total_mappings ?? 0} color="bg-purple-50" /> |
| <StatsCard icon="🧩" label="Techniques" value={stats?.total_techniques ?? 0} color="bg-green-50" /> |
| <StatsCard icon="📄" label="WQL Templates" value={stats?.total_queries ?? 0} color="bg-orange-50" /> |
| </div> |
| <div className="card p-5"> |
| <h3 className="font-semibold mb-4 text-sm uppercase tracking-wide text-gray-500">MITRE ATT&CK Technique Frequency (Top 10)</h3> |
| <TechFreqChart data={stats?.technique_frequency} /> |
| </div> |
| </div> |
| ); |
| } |
| |
| |
| |
| |
| |
| function RulesPage({ addToast }) { |
| const [ruleId, setRuleId] = useState(''); |
| const [results, setResults] = useState(null); |
| const [queries, setQueries] = useState({}); |
| const [loading, setLoading] = useState(false); |
| const [err, setErr] = useState(''); |
| |
| async function fetchMapping() { |
| if (!ruleId.trim()) { setErr('Enter a Rule ID.'); return; } |
| setErr(''); setLoading(true); setResults(null); setQueries({}); |
| try { |
| const r = await getResults(ruleId.trim()); |
| setResults(r.data); |
| |
| const qMap = {}; |
| for (const m of (r.data.detected || [])) { |
| try { |
| const qr = await getQueries(m.technique_id); |
| qMap[m.technique_id] = qr.data; |
| } catch { qMap[m.technique_id] = []; } |
| } |
| setQueries(qMap); |
| } catch (e) { |
| const msg = e.response?.data?.detail || 'No results found. Make sure the rule has been analyzed first.'; |
| setErr(msg); |
| } finally { setLoading(false); } |
| } |
| |
| async function copyQuery(q) { |
| await navigator.clipboard.writeText(q); |
| addToast('Query copied to clipboard!', 'success'); |
| } |
| |
| const detected = results?.detected || []; |
| const allMappings = results?.mappings || []; |
| |
| return ( |
| <div className="p-6 space-y-6 fade-in"> |
| <div> |
| <h2 className="text-xl font-bold">Rule Lookup</h2> |
| <p className="text-gray-500 text-sm">Enter a Wazuh Rule ID to view MITRE ATT&CK mappings and WQL investigation queries.</p> |
| </div> |
| |
| {/* Figure 4-10: Search bar */} |
| <div className="card p-5"> |
| <label className="block font-medium text-sm mb-2">Rule ID</label> |
| <div className="flex gap-3"> |
| <input |
| className="flex-1 border rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2" |
| style={{ '--tw-ring-color': 'var(--accent)' }} |
| placeholder="e.g. 18205, 597" value={ruleId} |
| onChange={e => setRuleId(e.target.value)} |
| onKeyDown={e => e.key === 'Enter' && fetchMapping()} /> |
| <button onClick={fetchMapping} disabled={loading} |
| className="accent-btn text-white px-6 py-2.5 rounded-xl font-medium hover:opacity-90 transition text-sm flex items-center gap-2"> |
| {loading |
| ? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> |
| : '🔍'} |
| Fetch Mapping |
| </button> |
| </div> |
| {err && <p className="text-red-500 text-sm mt-3 bg-red-50 px-3 py-2 rounded-lg">{err}</p>} |
| </div> |
| |
| {/* Figure 4-11 + 4-12 */} |
| {results && ( |
| <div className="space-y-6 fade-in"> |
| |
| {/* Figure 4-11: MITRE ATT&CK Techniques Distribution */} |
| <div className="card p-5"> |
| <div className="flex items-center justify-between mb-4"> |
| <div> |
| <h3 className="font-semibold text-base">MITRE ATT&CK Techniques Distribution</h3> |
| <p className="text-gray-500 text-xs mt-0.5">Rule ID: <span className="font-mono font-semibold accent-text">{results.rule_id}</span> — Top 5 techniques by confidence level</p> |
| </div> |
| <div className="flex gap-3 text-xs"> |
| <span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-emerald-500 inline-block" />High ≥70%</span> |
| <span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-amber-400 inline-block" />Med 40–70%</span> |
| <span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-red-500 inline-block" />Low <40%</span> |
| </div> |
| </div> |
| <TechniqueDistributionChart mappings={allMappings} /> |
| </div> |
| |
| {/* Figure 4-12: Returned Investigation Queries */} |
| <div className="card p-5"> |
| <div className="mb-4"> |
| <h3 className="font-semibold text-base">Returned Investigation Queries</h3> |
| <p className="text-gray-500 text-xs mt-0.5"> |
| WQL templates for primary technique |
| {detected.length > 1 ? ' + secondary (confidence ≥ 50%)' : ''}. |
| Replace <span className="font-mono bg-gray-100 px-1 rounded text-xs">${'{HOST}'}</span>,{' '} |
| <span className="font-mono bg-gray-100 px-1 rounded text-xs">${'{USER}'}</span>,{' '} |
| <span className="font-mono bg-gray-100 px-1 rounded text-xs">${'{IP}'}</span>{' '} |
| with actual alert values. |
| </p> |
| </div> |
| |
| {detected.length === 0 ? ( |
| <div className="text-center py-8"> |
| <p className="text-gray-400 text-sm">No techniques with confidence ≥ 50%.</p> |
| <p className="text-gray-300 text-xs mt-1">All techniques had low confidence scores.</p> |
| </div> |
| ) : ( |
| <div className="space-y-5"> |
| {detected.map((m, i) => { |
| const tQueries = queries[m.technique_id] || []; |
| const badge = m.is_primary |
| ? <span className="text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full font-medium">Primary</span> |
| : <span className="text-xs bg-amber-500 text-white px-2 py-0.5 rounded-full font-medium">Secondary</span>; |
| |
| return ( |
| <div key={i} className={`border rounded-xl p-4 ${m.is_primary ? 'border-blue-300 bg-blue-50/40' : 'border-amber-200 bg-amber-50/30'}`}> |
| {/* Technique header */} |
| <div className="flex items-center gap-2 mb-4"> |
| {badge} |
| <span className="font-mono font-bold text-sm">{m.technique_id}</span> |
| <ConfBadge pct={m.confidence_percent} /> |
| <span className="text-xs text-gray-400 ml-auto">Rank #{m.rank}</span> |
| </div> |
| |
| {/* WQL queries table */} |
| {tQueries.length === 0 ? ( |
| <div className="bg-gray-50 border border-dashed rounded-lg p-4 text-center"> |
| <p className="text-gray-400 text-sm">No WQL templates stored for <span className="font-mono">{m.technique_id}</span>.</p> |
| <p className="text-gray-300 text-xs mt-1">Admin can add templates in the WQL Templates panel.</p> |
| </div> |
| ) : ( |
| <div className="space-y-3"> |
| {tQueries.map((q, j) => ( |
| <div key={j} className="rounded-xl overflow-hidden border border-gray-200"> |
| {/* Template info row */} |
| <div className="flex items-center justify-between bg-gray-50 px-4 py-2 border-b border-gray-200"> |
| <div className="flex items-center gap-3"> |
| <span className="text-xs text-gray-400 font-mono">Template #{q.template_id}</span> |
| {q.purpose && <span className="text-xs font-medium text-gray-600">{q.purpose}</span>} |
| </div> |
| <button |
| onClick={() => copyQuery(q.wql_query)} |
| className="copy-btn text-xs bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded-lg font-medium transition flex items-center gap-1"> |
| 📋 Copy |
| </button> |
| </div> |
| {/* WQL block */} |
| <div className="bg-gray-900 px-4 py-3"> |
| <pre className="wql-block text-green-400 text-xs leading-relaxed">{q.wql_query}</pre> |
| </div> |
| {/* Note */} |
| {q.note && ( |
| <div className="bg-amber-50 px-4 py-2 border-t border-amber-100"> |
| <p className="text-amber-700 text-xs">💡 {q.note}</p> |
| </div> |
| )} |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| ); |
| })} |
| </div> |
| )} |
| |
| {/* Ignored techniques note */} |
| {allMappings.length > detected.length && ( |
| <div className="mt-4 pt-4 border-t border-dashed"> |
| <p className="text-xs text-gray-400"> |
| {allMappings.length - detected.length} technique(s) excluded from queries (confidence < 50%) —{' '} |
| {allMappings.slice(detected.length).map(m => m.technique_id).join(', ')} |
| </p> |
| </div> |
| )} |
| </div> |
| |
| </div> |
| )} |
| </div> |
| ); |
| } |
| |
| |
| function AdminPage({ addToast }) { |
| const [singleXml, setSingleXml] = useState(''); |
| const [analyzing, setAnalyzing] = useState(false); |
| const [result, setResult] = useState(null); |
| const [jobs, setJobs] = useState(store.get('jobs', [])); |
| const [dragOver, setDragOver] = useState(false); |
| const [uploadedFile, setUploadedFile] = useState(null); |
| const [uploadingFile, setUploadingFile] = useState(false); |
| const fileInputRef = useRef(null); |
| |
| |
| function addJob(file, status, rules = 1) { |
| const job = { id: Date.now(), file, status, progress: status === 'done' ? 100 : 0, rules, ts: new Date().toLocaleString() }; |
| const updated = [job, ...jobs]; |
| setJobs(updated); store.set('jobs', updated); |
| return { job, updated }; |
| } |
| |
| function updateJob(updated, jobId, patch) { |
| const next = updated.map(j => j.id === jobId ? { ...j, ...patch } : j); |
| setJobs(next); store.set('jobs', next); |
| } |
| |
| |
| async function submitSingleRule() { |
| if (!singleXml.trim()) { addToast('Paste a Wazuh rule XML first.', 'error'); return; } |
| setAnalyzing(true); setResult(null); |
| const { job, updated } = addJob('Single Rule', 'running', 1); |
| try { |
| const r = await analyzeRule(singleXml); |
| setResult(r.data); |
| updateJob(updated, job.id, { status: 'done', progress: 100 }); |
| addToast(`✅ Rule ${r.data.rule_id} — ${r.data.detected.length} technique(s) detected.`, 'success'); |
| } catch (e) { |
| const msg = e.response?.data?.detail || 'Analysis failed.'; |
| updateJob(updated, job.id, { status: 'failed', progress: 0 }); |
| addToast(msg, 'error'); |
| } finally { setAnalyzing(false); } |
| } |
| |
| |
| function parseRulesFromXml(xmlText) { |
| const parser = new DOMParser(); |
| const doc = parser.parseFromString(xmlText, 'text/xml'); |
| const rules = doc.querySelectorAll('rule'); |
| const xmls = []; |
| rules.forEach(rule => { |
| const serializer = new XMLSerializer(); |
| xmls.push(serializer.serializeToString(rule)); |
| }); |
| return xmls; |
| } |
| |
| async function processFileContent(file) { |
| if (!file.name.endsWith('.xml')) { addToast('Only .xml files are supported.', 'error'); return; } |
| setUploadedFile(file); |
| setUploadingFile(true); |
| setResult(null); |
| |
| const text = await file.text(); |
| const ruleXmls = parseRulesFromXml(text); |
| |
| if (ruleXmls.length === 0) { addToast('No <rule> elements found in file.', 'error'); setUploadingFile(false); return; } |
| |
| const { job, updated } = addJob(file.name, 'running', ruleXmls.length); |
| addToast(`📦 Processing ${ruleXmls.length} rule(s) from ${file.name}...`, 'info'); |
| |
| let done = 0, failed = 0; |
| for (const ruleXml of ruleXmls) { |
| try { |
| await analyzeRule(ruleXml); |
| done++; |
| } catch { failed++; } |
| const pct = Math.round(((done + failed) / ruleXmls.length) * 100); |
| updateJob(updated, job.id, { progress: pct }); |
| } |
| |
| const finalStatus = failed === ruleXmls.length ? 'failed' : 'done'; |
| updateJob(updated, job.id, { status: finalStatus, progress: 100 }); |
| setUploadingFile(false); |
| addToast(`✅ ${done} rule(s) processed${failed ? `, ${failed} failed` : ''}.`, finalStatus === 'done' ? 'success' : 'error'); |
| } |
| |
| function handleFileInput(e) { if (e.target.files[0]) processFileContent(e.target.files[0]); } |
| function handleDrop(e) { e.preventDefault(); setDragOver(false); if (e.dataTransfer.files[0]) processFileContent(e.dataTransfer.files[0]); } |
| function handleDragOver(e) { e.preventDefault(); setDragOver(true); } |
| function handleDragLeave() { setDragOver(false); } |
| |
| const isProcessing = analyzing || uploadingFile; |
| |
| return ( |
| <div className="p-6 space-y-6 fade-in"> |
| {/* Header */} |
| <div> |
| <h2 className="text-xl font-bold">Rule Mapping Panel</h2> |
| <p className="text-gray-500 text-sm">Upload Wazuh XML rule files or submit a single rule to map them to MITRE ATT&CK techniques.</p> |
| </div> |
| |
| {/* KPIs */} |
| <div className="grid grid-cols-2 gap-4 lg:grid-cols-3"> |
| <StatsCard icon="📊" label="Total Rules Processed" value={jobs.filter(j=>j.status==='done').reduce((a,j)=>a+j.rules,0)} color="bg-blue-50" /> |
| <StatsCard icon="✅" label="Jobs Completed" value={jobs.filter(j=>j.status==='done').length} color="bg-green-50" /> |
| <StatsCard icon="⏳" label="Processing Queue" value={jobs.filter(j=>j.status==='running').length} color="bg-yellow-50" /> |
| </div> |
| |
| {/* Upload Section — Figure 4-13 */} |
| <div className="card p-6"> |
| <h3 className="font-semibold mb-4">Upload Wazuh Rules</h3> |
| |
| {/* Drag & Drop Zone */} |
| <div |
| onClick={() => fileInputRef.current?.click()} |
| onDrop={handleDrop} |
| onDragOver={handleDragOver} |
| onDragLeave={handleDragLeave} |
| className={`border-2 border-dashed rounded-2xl p-10 text-center cursor-pointer transition-all select-none |
| ${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'}`}> |
| <input ref={fileInputRef} type="file" accept=".xml" className="hidden" onChange={handleFileInput} /> |
| <div className={`w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl |
| ${dragOver ? 'bg-blue-500 text-white' : 'bg-blue-100 text-blue-600'}`}> |
| {uploadingFile ? '⏳' : '⬆'} |
| </div> |
| {uploadedFile && !uploadingFile ? ( |
| <div> |
| <p className="font-semibold text-green-700">✅ {uploadedFile.name}</p> |
| <p className="text-xs text-gray-400 mt-1">Click to upload a different file</p> |
| </div> |
| ) : uploadingFile ? ( |
| <div> |
| <p className="font-semibold text-blue-700">Processing {uploadedFile?.name}...</p> |
| <p className="text-xs text-gray-400 mt-1">Analyzing rules with LLaMA + LogReg</p> |
| </div> |
| ) : ( |
| <div> |
| <p className="font-semibold text-gray-700">Drag and drop XML file(s) here, or click to browse</p> |
| <p className="text-xs text-gray-400 mt-1">Supported format: .xml | Max 10 MB per file</p> |
| </div> |
| )} |
| </div> |
| |
| <button |
| onClick={() => fileInputRef.current?.click()} |
| disabled={isProcessing} |
| className="w-full mt-4 accent-btn text-white py-3 rounded-xl font-medium hover:opacity-90 transition flex items-center justify-center gap-2"> |
| {uploadingFile |
| ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Processing...</> |
| : '📁 Upload File'} |
| </button> |
| </div> |
| |
| {} |
| <div className="flex items-center gap-4"> |
| <div className="flex-1 border-t border-gray-200" /> |
| <span className="text-xs text-gray-400 font-medium">Or paste rule(s) manually</span> |
| <div className="flex-1 border-t border-gray-200" /> |
| </div> |
| |
| {} |
| <div className="card p-6"> |
| <h3 className="font-semibold mb-3">Single Rule Input</h3> |
| <p className="text-xs text-gray-400 mb-3">Paste a single Wazuh rule in XML format</p> |
| <textarea |
| rows={6} |
| value={singleXml} |
| onChange={e => setSingleXml(e.target.value)} |
| placeholder={'<rule id="597" level="5">\n <description>Registry Key Entry Deleted.</description>\n <mitre><id>T1070.004</id></mitre>\n</rule>'} |
| className="w-full border rounded-xl px-4 py-3 font-mono text-sm focus:outline-none focus:ring-2 resize-none" /> |
| <button |
| onClick={submitSingleRule} |
| disabled={isProcessing} |
| className="mt-3 accent-btn text-white px-6 py-2.5 rounded-xl font-medium hover:opacity-90 transition text-sm flex items-center gap-2"> |
| {analyzing |
| ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Analyzing...</> |
| : '⚡ Submit Rule'} |
| </button> |
| </div> |
| |
| {} |
| {result && ( |
| <div className="card p-5 fade-in border-l-4 border-green-500"> |
| <h3 className="font-semibold text-green-700 mb-3">✅ Successful Submission — Rule #{result.rule_id}</h3> |
| <div className="bg-gray-50 rounded-lg p-3 mb-3"> |
| <p className="text-xs text-gray-500 mb-1">LLaMA Summary</p> |
| <p className="text-sm font-medium">{result.summary}</p> |
| <p className="text-xs text-gray-400 mt-1">Pipeline mode: <span className="font-mono font-semibold">{result.pipeline_mode}</span></p> |
| </div> |
| {result.detected.length === 0 ? ( |
| <p className="text-gray-400 text-sm">No techniques detected (confidence below thresholds).</p> |
| ) : ( |
| <table className="w-full text-sm"> |
| <thead><tr className="text-left text-gray-500 text-xs uppercase border-b"> |
| <th className="pb-2 pr-4">Technique</th> |
| <th className="pb-2 pr-4">Confidence</th> |
| <th className="pb-2 pr-4">Proba</th> |
| <th className="pb-2">Gap</th> |
| </tr></thead> |
| <tbody> |
| {result.detected.map((d, i) => ( |
| <tr key={i} className="border-b last:border-0"> |
| <td className="py-2 pr-4 font-mono font-semibold">{d.technique_id}</td> |
| <td className="py-2 pr-4"><ConfBadge pct={d.confidence_percent} /></td> |
| <td className="py-2 pr-4 text-gray-500">{(d.proba * 100).toFixed(1)}%</td> |
| <td className="py-2 text-green-600">+{d.gap.toFixed(3)}</td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| )} |
| </div> |
| )} |
| |
| {} |
| {jobs.length > 0 && ( |
| <div className="card p-5"> |
| <div className="flex items-center justify-between mb-4"> |
| <h3 className="font-semibold text-sm uppercase tracking-wide text-gray-500">Mapping Progress Table</h3> |
| <button onClick={() => { setJobs([]); store.set('jobs', []); }} className="text-xs text-gray-400 hover:text-red-500 transition">Clear</button> |
| </div> |
| <div className="overflow-x-auto"> |
| <table className="w-full text-sm"> |
| <thead><tr className="text-left text-gray-500 text-xs uppercase border-b bg-gray-50"> |
| <th className="px-3 py-2">Job ID</th> |
| <th className="px-3 py-2">File Name</th> |
| <th className="px-3 py-2">Status</th> |
| <th className="px-3 py-2">Progress</th> |
| <th className="px-3 py-2">Number of Rules</th> |
| <th className="px-3 py-2">Timestamp</th> |
| </tr></thead> |
| <tbody> |
| {jobs.map((j, i) => ( |
| <tr key={i} className="border-b last:border-0 hover:bg-gray-50"> |
| <td className="px-3 py-2 font-mono text-xs text-gray-400">{j.id.toString().slice(-6)}</td> |
| <td className="px-3 py-2 font-medium">{j.file}</td> |
| <td className="px-3 py-2"> |
| <span className={`px-2 py-0.5 rounded-full text-xs font-semibold |
| ${j.status === 'done' ? 'tag-high' : j.status === 'running' ? 'bg-blue-100 text-blue-700' : j.status === 'failed' ? 'tag-low' : 'bg-gray-100 text-gray-600'}`}> |
| {j.status} |
| </span> |
| </td> |
| <td className="px-3 py-2"> |
| <div className="flex items-center gap-2"> |
| <div className="w-20 bg-gray-200 rounded-full h-1.5"> |
| <div className="h-1.5 rounded-full accent-btn transition-all" style={{ width: j.progress + '%' }} /> |
| </div> |
| <span className="text-xs text-gray-400">{j.progress}%</span> |
| </div> |
| </td> |
| <td className="px-3 py-2 text-center">{j.rules}</td> |
| <td className="px-3 py-2 text-gray-400 text-xs">{j.ts}</td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
| |
| |
| function TemplatesPage({ addToast }) { |
| const [templates, setTemplates] = useState([]); |
| const [loading, setLoading] = useState(false); |
| const [techId, setTechId] = useState(''); |
| const [form, setForm] = useState({ technique_id: '', purpose: '', wql_query: '', note: '' }); |
| const [showForm, setShowForm] = useState(false); |
| |
| async function search() { |
| if (!techId.trim()) return; |
| setLoading(true); |
| try { |
| const r = await getQueries(techId.trim()); |
| setTemplates(r.data); |
| } catch { setTemplates([]); addToast(`No templates found for ${techId}`, 'error'); } |
| finally { setLoading(false); } |
| } |
| |
| async function save() { |
| if (!form.technique_id || !form.wql_query) { addToast('Technique ID and WQL are required.', 'error'); return; } |
| try { |
| await addTemplate(form); |
| addToast('Template saved!', 'success'); |
| setShowForm(false); |
| setForm({ technique_id: '', purpose: '', wql_query: '', note: '' }); |
| } catch { addToast('Failed to save template.', 'error'); } |
| } |
| |
| async function toggleActive(tpl) { |
| try { |
| await updateTemplate(tpl.template_id, { is_active: !tpl.is_active }); |
| addToast(`Template ${tpl.is_active ? 'disabled' : 'enabled'}.`, 'success'); |
| search(); |
| } catch { addToast('Update failed.', 'error'); } |
| } |
| |
| return ( |
| <div className="p-6 space-y-6 fade-in"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <h2 className="text-xl font-bold">WQL Templates</h2> |
| <p className="text-gray-500 text-sm">Manage static investigation query templates linked to MITRE techniques.</p> |
| </div> |
| <button onClick={() => setShowForm(!showForm)} |
| className="accent-btn text-white px-4 py-2 rounded-xl text-sm font-medium hover:opacity-90 transition"> |
| {showForm ? '✕ Cancel' : '+ Add Template'} |
| </button> |
| </div> |
| |
| {showForm && ( |
| <div className="card p-5 fade-in border-blue-200"> |
| <h3 className="font-semibold text-sm mb-4">New WQL Template</h3> |
| <div className="grid grid-cols-2 gap-4"> |
| <div> |
| <label className="text-xs font-medium text-gray-500 block mb-1">MITRE Technique ID *</label> |
| <input className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1" |
| placeholder="T1070.004" value={form.technique_id} onChange={e => setForm(f => ({ ...f, technique_id: e.target.value }))} /> |
| </div> |
| <div> |
| <label className="text-xs font-medium text-gray-500 block mb-1">Purpose</label> |
| <input className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1" |
| placeholder="Detect file deletion" value={form.purpose} onChange={e => setForm(f => ({ ...f, purpose: e.target.value }))} /> |
| </div> |
| </div> |
| <div className="mt-3"> |
| <label className="text-xs font-medium text-gray-500 block mb-1">WQL Query *</label> |
| <textarea rows={3} className="w-full border rounded-lg px-3 py-2 font-mono text-sm focus:outline-none focus:ring-1 resize-none" |
| placeholder="agent.name:${HOST} AND rule.description:"File deleted"" |
| value={form.wql_query} onChange={e => setForm(f => ({ ...f, wql_query: e.target.value }))} /> |
| </div> |
| <div className="mt-3"> |
| <label className="text-xs font-medium text-gray-500 block mb-1">Note</label> |
| <input className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1" |
| placeholder="Replace ${HOST} with actual hostname" value={form.note} onChange={e => setForm(f => ({ ...f, note: e.target.value }))} /> |
| </div> |
| <button onClick={save} className="mt-4 accent-btn text-white px-5 py-2 rounded-xl text-sm font-medium hover:opacity-90 transition"> |
| 💾 Save Template |
| </button> |
| </div> |
| )} |
| |
| <div className="card p-5"> |
| <div className="flex gap-3 mb-4"> |
| <input className="flex-1 border rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-1" |
| placeholder="Enter technique ID (e.g. T1484)" value={techId} |
| onChange={e => setTechId(e.target.value)} onKeyDown={e => e.key === 'Enter' && search()} /> |
| <button onClick={search} disabled={loading} |
| className="accent-btn text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:opacity-90 transition"> |
| Search |
| </button> |
| </div> |
| |
| {templates.length === 0 |
| ? <p className="text-gray-400 text-sm text-center py-6">Search for a technique ID to view its templates.</p> |
| : ( |
| <div className="overflow-x-auto"> |
| <table className="w-full text-sm"> |
| <thead><tr className="text-left text-gray-500 text-xs uppercase border-b bg-gray-50"> |
| <th className="px-3 py-2">Technique</th> |
| <th className="px-3 py-2">Template ID</th> |
| <th className="px-3 py-2">Purpose</th> |
| <th className="px-3 py-2">Investigation Query (WQL)</th> |
| <th className="px-3 py-2">Notes</th> |
| <th className="px-3 py-2">Action</th> |
| </tr></thead> |
| <tbody> |
| {templates.map((t, i) => ( |
| <tr key={i} className="border-b last:border-0 hover:bg-gray-50"> |
| <td className="px-3 py-3 font-mono font-semibold text-xs">{t.technique_id}</td> |
| <td className="px-3 py-3 text-gray-500 text-xs">{t.template_id}</td> |
| <td className="px-3 py-3 text-xs">{t.purpose || '—'}</td> |
| <td className="px-3 py-3 max-w-xs"> |
| <div className="bg-gray-900 text-green-400 rounded-lg px-3 py-2 font-mono text-xs overflow-x-auto">{t.wql_query}</div> |
| </td> |
| <td className="px-3 py-3 text-xs text-gray-500">{t.note || '—'}</td> |
| <td className="px-3 py-3"> |
| <button onClick={() => toggleActive(t)} |
| className="text-xs px-2 py-1 rounded-lg border hover:bg-gray-100 transition"> |
| Disable |
| </button> |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
| |
| |
| function SettingsPage({ user, setUser, theme, setTheme }) { |
| const [username, setUsername] = useState(user?.username || ''); |
| const [email, setEmail] = useState(user?.email || ''); |
| const [dark, setDark] = useState(document.documentElement.getAttribute('data-theme') === 'dark'); |
| const [saved, setSaved] = useState(false); |
| |
| function toggleDark(val) { |
| setDark(val); |
| document.documentElement.setAttribute('data-theme', val ? 'dark' : 'light'); |
| document.body.classList.toggle('dark', val); |
| store.set('dark', val); |
| } |
| |
| function saveProfile() { |
| setUser(u => ({ ...u, username, email })); |
| store.set('user', { ...user, username, email }); |
| setSaved(true); |
| setTimeout(() => setSaved(false), 2000); |
| } |
| |
| const themes = ['blue', 'purple', 'green', 'orange']; |
| const themeColors = { blue: '#3B82F6', purple: '#8B5CF6', green: '#10B981', orange: '#F59E0B' }; |
| |
| return ( |
| <div className="p-6 space-y-6 fade-in max-w-2xl"> |
| <h2 className="text-xl font-bold">Settings</h2> |
| |
| <div className="card p-5"> |
| <h3 className="font-semibold mb-4">Profile Settings</h3> |
| <div className="flex items-center gap-4 mb-5"> |
| <div className="w-16 h-16 rounded-full accent-btn flex items-center justify-center text-white text-2xl font-bold"> |
| {username?.[0]?.toUpperCase() || 'U'} |
| </div> |
| <div> |
| <p className="font-medium">{username}</p> |
| <p className="text-sm text-gray-500 capitalize">{user?.role}</p> |
| </div> |
| </div> |
| <div className="space-y-3"> |
| <div> |
| <label className="text-sm font-medium text-gray-600 block mb-1">Username</label> |
| <input className="w-full border rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-1" |
| value={username} onChange={e => setUsername(e.target.value)} /> |
| </div> |
| <div> |
| <label className="text-sm font-medium text-gray-600 block mb-1">Email</label> |
| <input className="w-full border rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-1" |
| type="email" value={email} onChange={e => setEmail(e.target.value)} /> |
| </div> |
| </div> |
| <button onClick={saveProfile} className="mt-4 accent-btn text-white px-5 py-2 rounded-xl text-sm font-medium hover:opacity-90 transition"> |
| {saved ? '✅ Saved!' : 'Save Changes'} |
| </button> |
| </div> |
| |
| <div className="card p-5"> |
| <h3 className="font-semibold mb-4">Appearance Settings</h3> |
| <div className="flex items-center justify-between mb-5"> |
| <div> |
| <p className="font-medium text-sm">Dark Mode</p> |
| <p className="text-xs text-gray-500">Toggle dark/light interface</p> |
| </div> |
| <button onClick={() => toggleDark(!dark)} |
| className={`w-12 h-6 rounded-full transition-all relative ${dark ? 'accent-btn' : 'bg-gray-200'}`}> |
| <span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all ${dark ? 'left-6' : 'left-0.5'}`} /> |
| </button> |
| </div> |
| <div> |
| <p className="font-medium text-sm mb-3">Color Theme</p> |
| <div className="flex gap-3"> |
| {themes.map(t => ( |
| <button key={t} onClick={() => { setTheme(t); document.documentElement.style.setProperty('--accent', themeColors[t]); store.set('theme', t); }} |
| className={`w-9 h-9 rounded-full border-2 transition-transform hover:scale-110 ${theme === t ? 'scale-110 border-gray-800' : 'border-transparent'}`} |
| style={{ background: themeColors[t] }} title={t} /> |
| ))} |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
| |
| |
| function DbViewerPage() { |
| const [tab, setTab] = useState('summary'); |
| const [data, setData] = useState(null); |
| const [loading, setLoading] = useState(false); |
| |
| const TABS = [ |
| { id: 'summary', label: 'ملخص', icon: '📊', fn: getDbSummary }, |
| { id: 'rules', label: 'القواعد', icon: '📌', fn: getDbRules }, |
| { id: 'mappings', label: 'المطابقات', icon: '🔗', fn: getDbMappings }, |
| { id: 'techniques', label: 'التقنيات', icon: '🧩', fn: getDbTechniques }, |
| { id: 'templates', label: 'قوالب WQL', icon: '📄', fn: getDbTemplates }, |
| ]; |
| |
| async function loadTab(id) { |
| setTab(id); |
| setLoading(true); |
| setData(null); |
| const t = TABS.find(x => x.id === id); |
| try { |
| const r = await t.fn(); |
| setData(r.data); |
| } catch (e) { |
| setData({ error: e.response?.data?.detail || 'Error loading data' }); |
| } finally { setLoading(false); } |
| } |
| |
| useEffect(() => { loadTab('summary'); }, []); |
| |
| function renderSummary(d) { |
| const items = [ |
| { label: 'Rules', value: d.rules, icon: '📌', color: 'bg-blue-50 text-blue-700' }, |
| { label: 'Techniques', value: d.techniques, icon: '🧩', color: 'bg-purple-50 text-purple-700' }, |
| { label: 'Rule Mappings', value: d.rule_mappings, icon: '🔗', color: 'bg-green-50 text-green-700' }, |
| { label: 'WQL Templates', value: d.query_templates, icon: '📄', color: 'bg-orange-50 text-orange-700' }, |
| { label: 'Mapping Jobs', value: d.mapping_jobs, icon: '⚙️', color: 'bg-gray-50 text-gray-700' }, |
| ]; |
| return ( |
| <div> |
| <div className="grid grid-cols-2 lg:grid-cols-5 gap-4"> |
| {items.map((x, i) => ( |
| <div key={i} className={`rounded-xl p-4 ${x.color} border border-current/10`}> |
| <div className="text-2xl mb-1">{x.icon}</div> |
| <div className="text-2xl font-bold">{x.value}</div> |
| <div className="text-xs font-medium opacity-70 mt-0.5">{x.label}</div> |
| </div> |
| ))} |
| </div> |
| <div className="mt-6 bg-gray-50 rounded-xl p-4 text-xs text-gray-500 space-y-1"> |
| <p>📍 Database: <span className="font-mono font-semibold">SQLite (murshid.db)</span></p> |
| <p>📍 Models dir: <span className="font-mono font-semibold">d:\GP\Needed</span></p> |
| </div> |
| </div> |
| ); |
| } |
| |
| function renderRules(rows) { |
| if (!rows.length) return <p className="text-gray-400 text-sm text-center py-8">No rules in database yet. Use POST /rules/analyze first.</p>; |
| return ( |
| <div className="overflow-x-auto"> |
| <table className="w-full text-sm"> |
| <thead><tr className="text-left text-xs text-gray-500 uppercase border-b bg-gray-50"> |
| <th className="px-3 py-2">Rule ID</th> |
| <th className="px-3 py-2">Job ID</th> |
| <th className="px-3 py-2">Embedding</th> |
| </tr></thead> |
| <tbody> |
| {rows.map((r, i) => ( |
| <tr key={i} className="border-b last:border-0 hover:bg-gray-50"> |
| <td className="px-3 py-2 font-mono font-bold accent-text">{r.rule_id}</td> |
| <td className="px-3 py-2 text-gray-400">{r.job_id ?? '—'}</td> |
| <td className="px-3 py-2"> |
| <span className={`text-xs px-2 py-0.5 rounded-full ${r.has_embedding ? 'tag-high' : 'bg-gray-100 text-gray-500'}`}> |
| {r.has_embedding ? '✓ stored' : 'none'} |
| </span> |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
| |
| function renderMappings(rows) { |
| if (!rows.length) return <p className="text-gray-400 text-sm text-center py-8">No mappings yet.</p>; |
| const grouped = rows.reduce((acc, m) => { |
| if (!acc[m.rule_id]) acc[m.rule_id] = []; |
| acc[m.rule_id].push(m); |
| return acc; |
| }, {}); |
| return ( |
| <div className="space-y-4"> |
| {Object.entries(grouped).map(([ruleId, ms]) => ( |
| <div key={ruleId} className="border rounded-xl overflow-hidden"> |
| <div className="bg-gray-50 px-4 py-2 border-b flex items-center gap-2"> |
| <span className="font-mono font-bold accent-text text-sm">Rule #{ruleId}</span> |
| <span className="text-xs text-gray-400">— {ms.length} techniques</span> |
| </div> |
| <table className="w-full text-xs"> |
| <thead><tr className="text-left text-gray-400 uppercase border-b"> |
| <th className="px-3 py-2">Rank</th> |
| <th className="px-3 py-2">Technique</th> |
| <th className="px-3 py-2">Confidence %</th> |
| <th className="px-3 py-2">Score</th> |
| <th className="px-3 py-2">Level</th> |
| </tr></thead> |
| <tbody> |
| {ms.map((m, j) => { |
| const pct = m.confidence_pct; |
| const level = pct >= 70 ? { lbl: 'High', cls: 'tag-high' } : pct >= 40 ? { lbl: 'Med', cls: 'tag-medium' } : { lbl: 'Low', cls: 'tag-low' }; |
| return ( |
| <tr key={j} className="border-b last:border-0 hover:bg-gray-50"> |
| <td className="px-3 py-2 text-gray-400">#{j+1}</td> |
| <td className="px-3 py-2 font-mono font-semibold">{m.technique_id}</td> |
| <td className="px-3 py-2"> |
| <div className="flex items-center gap-2"> |
| <div className="w-20 bg-gray-200 rounded-full h-1.5"> |
| <div className="h-1.5 rounded-full" style={{ width: Math.min(pct,100)+'%', background: pct>=70?'#10b981':pct>=40?'#f59e0b':'#ef4444' }} /> |
| </div> |
| <span className="font-medium">{pct}%</span> |
| </div> |
| </td> |
| <td className="px-3 py-2 font-mono">{m.confidence_score}</td> |
| <td className="px-3 py-2"><span className={`text-xs px-2 py-0.5 rounded-full ${level.cls}`}>{level.lbl}</span></td> |
| </tr> |
| ); |
| })} |
| </tbody> |
| </table> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
| |
| function renderTechniques(rows) { |
| if (!rows.length) return <p className="text-gray-400 text-sm text-center py-8">No techniques stored yet.</p>; |
| return ( |
| <div className="overflow-x-auto"> |
| <table className="w-full text-sm"> |
| <thead><tr className="text-left text-xs text-gray-500 uppercase border-b bg-gray-50"> |
| <th className="px-3 py-2">Technique ID</th> |
| <th className="px-3 py-2">Name</th> |
| <th className="px-3 py-2">Tactic</th> |
| </tr></thead> |
| <tbody> |
| {rows.map((t, i) => ( |
| <tr key={i} className="border-b last:border-0 hover:bg-gray-50"> |
| <td className="px-3 py-2 font-mono font-bold accent-text">{t.technique_id}</td> |
| <td className="px-3 py-2">{t.technique_name || '—'}</td> |
| <td className="px-3 py-2 text-gray-400">{t.tactic || '—'}</td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
| |
| function renderTemplates(rows) { |
| if (!rows.length) return <p className="text-gray-400 text-sm text-center py-8">No WQL templates stored yet.</p>; |
| return ( |
| <div className="overflow-x-auto"> |
| <table className="w-full text-sm"> |
| <thead><tr className="text-left text-xs text-gray-500 uppercase border-b bg-gray-50"> |
| <th className="px-3 py-2">ID</th> |
| <th className="px-3 py-2">Technique</th> |
| <th className="px-3 py-2">Purpose</th> |
| <th className="px-3 py-2">WQL Query</th> |
| <th className="px-3 py-2">Note</th> |
| <th className="px-3 py-2">Active</th> |
| </tr></thead> |
| <tbody> |
| {rows.map((t, i) => ( |
| <tr key={i} className={`border-b last:border-0 hover:bg-gray-50 ${!t.is_active ? 'opacity-40' : ''}`}> |
| <td className="px-3 py-2 text-gray-400 text-xs">#{t.template_id}</td> |
| <td className="px-3 py-2 font-mono font-bold accent-text">{t.technique_id}</td> |
| <td className="px-3 py-2 text-xs">{t.purpose || '—'}</td> |
| <td className="px-3 py-2 max-w-xs"> |
| <div className="bg-gray-900 text-green-400 rounded px-2 py-1 font-mono text-xs overflow-x-auto max-w-xs">{t.wql_query}</div> |
| </td> |
| <td className="px-3 py-2 text-xs text-gray-500">{t.note || '—'}</td> |
| <td className="px-3 py-2"> |
| <span className={`text-xs px-2 py-0.5 rounded-full ${t.is_active ? 'tag-high' : 'tag-low'}`}> |
| {t.is_active ? 'Active' : 'Disabled'} |
| </span> |
| </td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
| |
| function renderContent() { |
| if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>; |
| if (!data) return null; |
| if (data.error) return <p className="text-red-500 text-sm py-8 text-center">{data.error}</p>; |
| if (tab === 'summary') return renderSummary(data); |
| if (tab === 'rules') return renderRules(Array.isArray(data) ? data : []); |
| if (tab === 'mappings') return renderMappings(Array.isArray(data) ? data : []); |
| if (tab === 'techniques') return renderTechniques(Array.isArray(data) ? data : []); |
| if (tab === 'templates') return renderTemplates(Array.isArray(data) ? data : []); |
| return null; |
| } |
| |
| return ( |
| <div className="p-6 space-y-5 fade-in"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <h2 className="text-xl font-bold">نتائج قاعدة البيانات</h2> |
| <p className="text-gray-500 text-sm mt-0.5">استعراض محتويات قاعدة بيانات SQLite — للفحص والاختبار</p> |
| </div> |
| <button onClick={() => loadTab(tab)} |
| className="flex items-center gap-2 text-sm border px-3 py-2 rounded-xl hover:bg-gray-50 transition"> |
| 🔄 تحديث |
| </button> |
| </div> |
| |
| {/* Tabs */} |
| <div className="flex gap-2 border-b overflow-x-auto"> |
| {TABS.map(t => ( |
| <button key={t.id} onClick={() => loadTab(t.id)} |
| className={`px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition |
| ${tab === t.id ? 'border-current accent-text border-b-2' : 'border-transparent text-gray-500 hover:text-gray-700'}`}> |
| {t.icon} {t.label} |
| </button> |
| ))} |
| </div> |
| |
| {/* Content */} |
| <div className="card p-5"> |
| {renderContent()} |
| </div> |
| </div> |
| ); |
| } |
| |
| |
| function App() { |
| const [user, setUser] = useState(() => store.get('user', null)); |
| const [page, setPage] = useState('dashboard'); |
| const [theme, setTheme] = useState(() => store.get('theme', 'blue')); |
| const [toasts, setToasts] = useState([]); |
| const [backendOk, setBackendOk] = useState(false); |
| |
| |
| useEffect(() => { |
| const colors = { blue:'#3B82F6', purple:'#8B5CF6', green:'#10B981', orange:'#F59E0B' }; |
| document.documentElement.style.setProperty('--accent', colors[theme] || colors.blue); |
| const dark = store.get('dark', false); |
| if (dark) document.documentElement.setAttribute('data-theme', 'dark'); |
| }, []); |
| |
| |
| useEffect(() => { |
| const check = () => getHealth().then(() => setBackendOk(true)).catch(() => setBackendOk(false)); |
| check(); |
| const id = setInterval(check, 15000); |
| return () => clearInterval(id); |
| }, []); |
| |
| function addToast(msg, type = 'info') { |
| const id = Date.now(); |
| setToasts(t => [...t, { id, msg, type }]); |
| } |
| |
| function removeToast(id) { setToasts(t => t.filter(x => x.id !== id)); } |
| |
| function handleLogin(u) { |
| setUser(u); store.set('user', u); |
| } |
| |
| function handleLogout() { |
| setUser(null); store.set('user', null); setPage('dashboard'); |
| } |
| |
| const PAGE_TITLES = { |
| dashboard: '🏠 Dashboard', |
| rules: '🔍 Rule Lookup', |
| dbviewer: '🗄️ نتائج قاعدة البيانات', |
| admin: '⚙️ Rule Mapping', |
| templates: '📋 WQL Templates', |
| settings: '👤 Settings', |
| }; |
| |
| if (!user) return <LoginPage onLogin={handleLogin} />; |
| |
| return ( |
| <div className="flex min-h-screen"> |
| <Sidebar page={page} onNav={setPage} user={user} onLogout={handleLogout} /> |
| <div className="flex-1 flex flex-col overflow-hidden"> |
| <Header title={PAGE_TITLES[page] || 'Murshid'} backendOk={backendOk} /> |
| <main className="flex-1 overflow-y-auto"> |
| {page === 'dashboard' && <DashboardPage user={user} />} |
| {page === 'rules' && <RulesPage addToast={addToast} />} |
| {page === 'dbviewer' && <DbViewerPage />} |
| {page === 'admin' && <AdminPage addToast={addToast} />} |
| {page === 'templates' && <TemplatesPage addToast={addToast} />} |
| {page === 'settings' && <SettingsPage user={user} setUser={setUser} theme={theme} setTheme={setTheme} />} |
| </main> |
| </div> |
| {toasts.map(t => <Toast key={t.id} msg={t.msg} type={t.type} onClose={() => removeToast(t.id)} />)} |
| </div> |
| ); |
| } |
| |
| |
| const root = ReactDOM.createRoot(document.getElementById('root')); |
| root.render(<App />); |
| </script> |
| </body> |
| </html> |
|
|