Add backend modules and frontend components for StrikePackageGPT expansion
Co-authored-by: mblanke <9078342+mblanke@users.noreply.github.com>
345
services/dashboard/ExplainButton.jsx
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* ExplainButton Component
|
||||
* Reusable inline "Explain" button for configs, logs, and errors
|
||||
* Shows modal/popover with LLM-powered explanation
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const ExplainButton = ({
|
||||
type = 'config', // config, log, error, scan_result
|
||||
content,
|
||||
context = {},
|
||||
size = 'small',
|
||||
style = {}
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [explanation, setExplanation] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleExplain = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/explain', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
content,
|
||||
context
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get explanation');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setExplanation(data);
|
||||
} catch (err) {
|
||||
console.error('Error getting explanation:', err);
|
||||
setError('Failed to load explanation. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setExplanation(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const buttonSizes = {
|
||||
small: { padding: '4px 8px', fontSize: '12px' },
|
||||
medium: { padding: '6px 12px', fontSize: '14px' },
|
||||
large: { padding: '8px 16px', fontSize: '16px' }
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
...buttonSizes[size],
|
||||
backgroundColor: '#3498DB',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
...style
|
||||
};
|
||||
|
||||
const renderExplanation = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ color: '#E74C3C', padding: '10px' }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '10px' }}>⏳</div>
|
||||
<div>Generating explanation...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!explanation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render based on explanation type
|
||||
switch (type) {
|
||||
case 'config':
|
||||
return (
|
||||
<div style={{ padding: '15px' }}>
|
||||
<h3 style={{ margin: '0 0 10px 0', fontSize: '18px' }}>
|
||||
{explanation.config_key || 'Configuration'}
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<strong>Current Value:</strong>
|
||||
<code style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
marginLeft: '5px'
|
||||
}}>
|
||||
{explanation.current_value}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px', lineHeight: '1.6' }}>
|
||||
<strong>What it does:</strong>
|
||||
<p style={{ margin: '5px 0' }}>{explanation.description}</p>
|
||||
</div>
|
||||
|
||||
{explanation.example && (
|
||||
<div style={{ marginBottom: '15px', lineHeight: '1.6' }}>
|
||||
<strong>Example:</strong>
|
||||
<p style={{ margin: '5px 0', fontStyle: 'italic' }}>{explanation.example}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explanation.value_analysis && (
|
||||
<div style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#E8F4F8', borderRadius: '4px' }}>
|
||||
<strong>Analysis:</strong> {explanation.value_analysis}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{explanation.recommendations && explanation.recommendations.length > 0 && (
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<strong>Recommendations:</strong>
|
||||
<ul style={{ margin: '5px 0', paddingLeft: '20px' }}>
|
||||
{explanation.recommendations.map((rec, i) => (
|
||||
<li key={i} style={{ margin: '5px 0' }}>{rec}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '15px', paddingTop: '15px', borderTop: '1px solid #ddd' }}>
|
||||
{explanation.requires_restart && (
|
||||
<div>⚠️ Changing this setting requires a restart</div>
|
||||
)}
|
||||
{!explanation.safe_to_change && (
|
||||
<div>⚠️ Use caution when changing this setting</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div style={{ padding: '15px' }}>
|
||||
<h3 style={{ margin: '0 0 10px 0', fontSize: '18px', color: '#E74C3C' }}>
|
||||
Error Explanation
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#fef5e7', borderRadius: '4px', fontSize: '14px' }}>
|
||||
<strong>Original Error:</strong>
|
||||
<div style={{ marginTop: '5px', fontFamily: 'monospace', fontSize: '12px' }}>
|
||||
{explanation.original_error}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<strong>What went wrong:</strong>
|
||||
<p style={{ margin: '5px 0', lineHeight: '1.6' }}>{explanation.plain_english}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<strong>Likely causes:</strong>
|
||||
<ul style={{ margin: '5px 0', paddingLeft: '20px' }}>
|
||||
{explanation.likely_causes?.map((cause, i) => (
|
||||
<li key={i} style={{ margin: '5px 0' }}>{cause}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#E8F8F5', borderRadius: '4px' }}>
|
||||
<strong>💡 How to fix it:</strong>
|
||||
<ol style={{ margin: '5px 0', paddingLeft: '20px' }}>
|
||||
{explanation.suggested_fixes?.map((fix, i) => (
|
||||
<li key={i} style={{ margin: '5px 0' }}>{fix}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '15px' }}>
|
||||
Severity: <span style={{
|
||||
color: explanation.severity === 'critical' ? '#E74C3C' :
|
||||
explanation.severity === 'high' ? '#E67E22' :
|
||||
explanation.severity === 'medium' ? '#F39C12' : '#95A5A6',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{(explanation.severity || 'unknown').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'log':
|
||||
return (
|
||||
<div style={{ padding: '15px' }}>
|
||||
<h3 style={{ margin: '0 0 10px 0', fontSize: '18px' }}>
|
||||
Log Entry Explanation
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '15px', padding: '10px', backgroundColor: '#f5f5f5', borderRadius: '4px', fontSize: '13px', fontFamily: 'monospace' }}>
|
||||
{explanation.log_entry}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<strong>Level:</strong>
|
||||
<span style={{
|
||||
marginLeft: '5px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
backgroundColor: explanation.log_level === 'ERROR' ? '#E74C3C' :
|
||||
explanation.log_level === 'WARNING' ? '#F39C12' :
|
||||
explanation.log_level === 'INFO' ? '#3498DB' : '#95A5A6',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{explanation.log_level}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{explanation.timestamp && (
|
||||
<div style={{ marginBottom: '15px', fontSize: '14px', color: '#666' }}>
|
||||
<strong>Time:</strong> {explanation.timestamp}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '15px', lineHeight: '1.6' }}>
|
||||
<strong>What this means:</strong>
|
||||
<p style={{ margin: '5px 0' }}>{explanation.explanation}</p>
|
||||
</div>
|
||||
|
||||
{explanation.action_needed && explanation.next_steps && explanation.next_steps.length > 0 && (
|
||||
<div style={{ padding: '10px', backgroundColor: '#FEF5E7', borderRadius: '4px' }}>
|
||||
<strong>⚠️ Action needed:</strong>
|
||||
<ul style={{ margin: '5px 0', paddingLeft: '20px' }}>
|
||||
{explanation.next_steps.map((step, i) => (
|
||||
<li key={i} style={{ margin: '5px 0' }}>{step}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div style={{ padding: '15px' }}>
|
||||
<div>{explanation.explanation || 'No explanation available.'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleExplain}
|
||||
onMouseEnter={(e) => e.target.style.backgroundColor = '#2980B9'}
|
||||
onMouseLeave={(e) => e.target.style.backgroundColor = '#3498DB'}
|
||||
style={buttonStyle}
|
||||
title="Get AI-powered explanation"
|
||||
>
|
||||
<span>❓</span>
|
||||
<span>Explain</span>
|
||||
</button>
|
||||
|
||||
{showModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999
|
||||
}}
|
||||
onClick={closeModal}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
backgroundColor: 'white',
|
||||
padding: '15px',
|
||||
borderBottom: '1px solid #ddd',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: '20px' }}>Explanation</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderExplanation()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExplainButton;
|
||||
487
services/dashboard/GuidedWizard.jsx
Normal file
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* GuidedWizard Component
|
||||
* Multi-step wizard for onboarding flows
|
||||
* Types: create_operation, onboard_agent, run_scan, first_time_setup
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const GuidedWizard = ({
|
||||
wizardType = 'first_time_setup',
|
||||
onComplete,
|
||||
onCancel,
|
||||
initialData = {}
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState(initialData);
|
||||
const [stepHelp, setStepHelp] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const wizardConfigs = {
|
||||
create_operation: {
|
||||
title: 'Create New Operation',
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
title: 'Operation Name and Type',
|
||||
fields: [
|
||||
{ name: 'operation_name', label: 'Operation Name', type: 'text', required: true, placeholder: 'Q4 Security Assessment' },
|
||||
{ name: 'operation_type', label: 'Operation Type', type: 'select', required: true, options: [
|
||||
{ value: 'external', label: 'External Penetration Test' },
|
||||
{ value: 'internal', label: 'Internal Network Assessment' },
|
||||
{ value: 'webapp', label: 'Web Application Test' },
|
||||
{ value: 'wireless', label: 'Wireless Security Assessment' }
|
||||
]}
|
||||
]
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: 'Define Target Scope',
|
||||
fields: [
|
||||
{ name: 'target_range', label: 'Target Network Range', type: 'text', required: true, placeholder: '192.168.1.0/24' },
|
||||
{ name: 'excluded_hosts', label: 'Excluded Hosts (comma-separated)', type: 'text', placeholder: '192.168.1.1, 192.168.1.254' },
|
||||
{ name: 'domains', label: 'Target Domains', type: 'textarea', placeholder: 'example.com\napp.example.com' }
|
||||
]
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: 'Configure Assessment Tools',
|
||||
fields: [
|
||||
{ name: 'scan_intensity', label: 'Scan Intensity', type: 'select', required: true, options: [
|
||||
{ value: '1', label: 'Stealth (Slowest, least detectable)' },
|
||||
{ value: '3', label: 'Balanced (Recommended)' },
|
||||
{ value: '5', label: 'Aggressive (Fastest, easily detected)' }
|
||||
]},
|
||||
{ name: 'tools', label: 'Tools to Use', type: 'multiselect', options: [
|
||||
{ value: 'nmap', label: 'Nmap (Network Scanning)' },
|
||||
{ value: 'nikto', label: 'Nikto (Web Server Scanning)' },
|
||||
{ value: 'gobuster', label: 'Gobuster (Directory Enumeration)' },
|
||||
{ value: 'sqlmap', label: 'SQLMap (SQL Injection Testing)' }
|
||||
]}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
run_scan: {
|
||||
title: 'Run Security Scan',
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
title: 'Select Scan Tool',
|
||||
fields: [
|
||||
{ name: 'tool', label: 'Security Tool', type: 'select', required: true, options: [
|
||||
{ value: 'nmap', label: 'Nmap - Network Scanner' },
|
||||
{ value: 'nikto', label: 'Nikto - Web Server Scanner' },
|
||||
{ value: 'gobuster', label: 'Gobuster - Directory/File Discovery' },
|
||||
{ value: 'sqlmap', label: 'SQLMap - SQL Injection' },
|
||||
{ value: 'whatweb', label: 'WhatWeb - Technology Detection' }
|
||||
]}
|
||||
]
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: 'Specify Target',
|
||||
fields: [
|
||||
{ name: 'target', label: 'Target', type: 'text', required: true, placeholder: '192.168.1.0/24 or example.com' },
|
||||
{ name: 'ports', label: 'Ports (optional)', type: 'text', placeholder: '80,443,8080 or 1-1000' }
|
||||
]
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: 'Scan Options',
|
||||
fields: [
|
||||
{ name: 'scan_type', label: 'Scan Type', type: 'select', required: true, options: [
|
||||
{ value: 'quick', label: 'Quick Scan (Fast, common ports)' },
|
||||
{ value: 'full', label: 'Full Scan (Comprehensive, slower)' },
|
||||
{ value: 'stealth', label: 'Stealth Scan (Slow, harder to detect)' },
|
||||
{ value: 'vuln', label: 'Vulnerability Scan (Checks for known vulns)' }
|
||||
]},
|
||||
{ name: 'timeout', label: 'Timeout (seconds)', type: 'number', placeholder: '300' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
first_time_setup: {
|
||||
title: 'Welcome to StrikePackageGPT',
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
title: 'Welcome',
|
||||
fields: [
|
||||
{ name: 'user_name', label: 'Your Name', type: 'text', placeholder: 'John Doe' },
|
||||
{ name: 'skill_level', label: 'Security Testing Experience', type: 'select', required: true, options: [
|
||||
{ value: 'beginner', label: 'Beginner - Learning the basics' },
|
||||
{ value: 'intermediate', label: 'Intermediate - Some experience' },
|
||||
{ value: 'advanced', label: 'Advanced - Professional pentester' }
|
||||
]}
|
||||
]
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: 'Configure LLM Provider',
|
||||
fields: [
|
||||
{ name: 'llm_provider', label: 'LLM Provider', type: 'select', required: true, options: [
|
||||
{ value: 'ollama', label: 'Ollama (Local, Free)' },
|
||||
{ value: 'openai', label: 'OpenAI (Cloud, Requires API Key)' },
|
||||
{ value: 'anthropic', label: 'Anthropic Claude (Cloud, Requires API Key)' }
|
||||
]},
|
||||
{ name: 'api_key', label: 'API Key (if using cloud provider)', type: 'password', placeholder: 'sk-...' }
|
||||
]
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: 'Review and Finish',
|
||||
fields: []
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const config = wizardConfigs[wizardType] || wizardConfigs.first_time_setup;
|
||||
const totalSteps = config.steps.length;
|
||||
const currentStepConfig = config.steps[currentStep - 1];
|
||||
|
||||
useEffect(() => {
|
||||
fetchStepHelp();
|
||||
}, [currentStep]);
|
||||
|
||||
const fetchStepHelp = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/wizard/help?type=${wizardType}&step=${currentStep}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStepHelp(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch step help:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName, value) => {
|
||||
setFormData(prev => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
const validateCurrentStep = () => {
|
||||
const requiredFields = currentStepConfig.fields.filter(f => f.required);
|
||||
for (const field of requiredFields) {
|
||||
if (!formData[field.name]) {
|
||||
setError(`${field.label} is required`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!validateCurrentStep()) return;
|
||||
|
||||
if (currentStep < totalSteps) {
|
||||
setCurrentStep(prev => prev + 1);
|
||||
} else {
|
||||
handleComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(prev => prev - 1);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!validateCurrentStep()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (onComplete) {
|
||||
await onComplete(formData);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to complete wizard: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (field) => {
|
||||
const commonStyle = {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'password':
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type={field.type}
|
||||
value={formData[field.name] || ''}
|
||||
onChange={(e) => handleFieldChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
style={commonStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<textarea
|
||||
value={formData[field.name] || ''}
|
||||
onChange={(e) => handleFieldChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
style={{ ...commonStyle, resize: 'vertical' }}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
value={formData[field.name] || ''}
|
||||
onChange={(e) => handleFieldChange(field.name, e.target.value)}
|
||||
style={commonStyle}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{field.options?.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case 'multiselect':
|
||||
const selectedValues = formData[field.name] || [];
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{field.options?.map(opt => (
|
||||
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(opt.value)}
|
||||
onChange={(e) => {
|
||||
const newValues = e.target.checked
|
||||
? [...selectedValues, opt.value]
|
||||
: selectedValues.filter(v => v !== opt.value);
|
||||
handleFieldChange(field.name, newValues);
|
||||
}}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
width: '90%',
|
||||
maxWidth: '700px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderBottom: '2px solid #3498DB',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 10px 0', color: '#2C3E50' }}>{config.title}</h2>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div style={{ display: 'flex', gap: '5px', marginTop: '15px' }}>
|
||||
{config.steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '4px',
|
||||
backgroundColor: index + 1 <= currentStep ? '#3498DB' : '#ddd',
|
||||
borderRadius: '2px',
|
||||
transition: 'background-color 0.3s'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', fontSize: '14px', color: '#666' }}>
|
||||
Step {currentStep} of {totalSteps}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div style={{ padding: '30px' }}>
|
||||
<h3 style={{ margin: '0 0 20px 0', color: '#34495E' }}>
|
||||
{currentStepConfig.title}
|
||||
</h3>
|
||||
|
||||
{/* Help section */}
|
||||
{stepHelp && (
|
||||
<div style={{
|
||||
padding: '15px',
|
||||
backgroundColor: '#E8F4F8',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '20px',
|
||||
borderLeft: '4px solid #3498DB'
|
||||
}}>
|
||||
{stepHelp.description && (
|
||||
<p style={{ margin: '0 0 10px 0' }}>{stepHelp.description}</p>
|
||||
)}
|
||||
{stepHelp.tips && stepHelp.tips.length > 0 && (
|
||||
<div>
|
||||
<strong style={{ fontSize: '14px' }}>💡 Tips:</strong>
|
||||
<ul style={{ margin: '5px 0 0 0', paddingLeft: '20px', fontSize: '14px' }}>
|
||||
{stepHelp.tips.map((tip, i) => (
|
||||
<li key={i} style={{ margin: '5px 0' }}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form fields */}
|
||||
{currentStepConfig.fields.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{currentStepConfig.fields.map(field => (
|
||||
<div key={field.name}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '500',
|
||||
color: '#2C3E50'
|
||||
}}>
|
||||
{field.label}
|
||||
{field.required && <span style={{ color: '#E74C3C' }}> *</span>}
|
||||
</label>
|
||||
{renderField(field)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||
<h4>Review Your Settings</h4>
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
textAlign: 'left',
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '15px',
|
||||
borderRadius: '4px',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{Object.entries(formData).map(([key, value]) => (
|
||||
<div key={key} style={{ marginBottom: '10px' }}>
|
||||
<strong>{key}:</strong> {Array.isArray(value) ? value.join(', ') : value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#FCE4E4',
|
||||
color: '#E74C3C',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderTop: '1px solid #ddd',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: '1px solid #95A5A6',
|
||||
backgroundColor: 'white',
|
||||
color: '#666',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
{currentStep > 1 && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: '1px solid #3498DB',
|
||||
backgroundColor: 'white',
|
||||
color: '#3498DB',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
backgroundColor: loading ? '#95A5A6' : '#3498DB',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Processing...' : currentStep === totalSteps ? 'Finish' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuidedWizard;
|
||||
424
services/dashboard/HelpChat.jsx
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* HelpChat Component
|
||||
* Persistent side-panel chat with LLM-powered help
|
||||
* Context-aware and maintains conversation history
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const HelpChat = ({
|
||||
isOpen = false,
|
||||
onClose,
|
||||
currentPage = 'dashboard',
|
||||
context = {}
|
||||
}) => {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sessionId] = useState(() => `session-${Date.now()}`);
|
||||
const messagesEndRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && messages.length === 0) {
|
||||
// Add welcome message
|
||||
setMessages([{
|
||||
role: 'assistant',
|
||||
content: `👋 Hi! I'm your AI assistant for StrikePackageGPT. I can help you with:
|
||||
|
||||
• Understanding security tools and commands
|
||||
• Interpreting scan results
|
||||
• Writing nmap, nikto, and other tool commands
|
||||
• Navigating the platform
|
||||
• Security best practices
|
||||
|
||||
What would you like help with?`,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputText.trim() || isLoading) return;
|
||||
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: inputText,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Build context string
|
||||
const contextString = `User is on ${currentPage} page. ${JSON.stringify(context)}`;
|
||||
|
||||
const response = await fetch('/api/llm/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: inputText,
|
||||
session_id: sessionId,
|
||||
context: contextString
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get response');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const assistantMessage = {
|
||||
role: 'assistant',
|
||||
content: data.message || data.content || 'I apologize, I had trouble processing that request.',
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: '❌ Sorry, I encountered an error. Please try again.',
|
||||
timestamp: new Date(),
|
||||
isError: true
|
||||
}]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// Could show a toast notification here
|
||||
console.log('Copied to clipboard');
|
||||
});
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
if (window.confirm('Clear all chat history?')) {
|
||||
setMessages([{
|
||||
role: 'assistant',
|
||||
content: 'Chat history cleared. How can I help you?',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessage = (message, index) => {
|
||||
const isUser = message.role === 'user';
|
||||
const isError = message.isError;
|
||||
|
||||
// Check if message contains code blocks
|
||||
const hasCode = message.content.includes('```');
|
||||
let renderedContent;
|
||||
|
||||
if (hasCode) {
|
||||
// Simple code block rendering
|
||||
const parts = message.content.split(/(```[\s\S]*?```)/g);
|
||||
renderedContent = parts.map((part, i) => {
|
||||
if (part.startsWith('```')) {
|
||||
const code = part.slice(3, -3).trim();
|
||||
const [lang, ...codeLines] = code.split('\n');
|
||||
const codeText = codeLines.join('\n');
|
||||
|
||||
return (
|
||||
<div key={i} style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
margin: '10px 0',
|
||||
position: 'relative',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
overflowX: 'auto'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
marginBottom: '5px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span>{lang || 'code'}</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(codeText)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
border: 'none',
|
||||
backgroundColor: '#ddd',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||
<code>{codeText}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div key={i} style={{ whiteSpace: 'pre-wrap' }}>{part}</div>;
|
||||
});
|
||||
} else {
|
||||
renderedContent = <div style={{ whiteSpace: 'pre-wrap' }}>{message.content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: isUser ? 'flex-end' : 'flex-start',
|
||||
marginBottom: '15px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: isError ? '#FCE4E4' : isUser ? '#3498DB' : '#ECF0F1',
|
||||
color: isError ? '#E74C3C' : isUser ? 'white' : '#2C3E50',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
boxShadow: '0 2px 5px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
{renderedContent}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: isUser ? 'rgba(255,255,255,0.7)' : '#95A5A6',
|
||||
marginTop: '5px',
|
||||
textAlign: 'right'
|
||||
}}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const quickActions = [
|
||||
{ label: '📝 Write nmap command', prompt: 'How do I write an nmap command to scan a network?' },
|
||||
{ label: '🔍 Interpret results', prompt: 'Help me understand these scan results' },
|
||||
{ label: '🛠️ Use sqlmap', prompt: 'How do I use sqlmap to test for SQL injection?' },
|
||||
{ label: '📊 Generate report', prompt: 'How do I generate a security assessment report?' }
|
||||
];
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '400px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '-4px 0 15px rgba(0,0,0,0.1)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 9998
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#3498DB',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: '2px solid #2980B9'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 5px 0', fontSize: '18px' }}>💬 AI Assistant</h3>
|
||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>
|
||||
Ask me anything about security testing
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={clearChat}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
padding: '5px'
|
||||
}}
|
||||
title="Clear chat"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '24px',
|
||||
padding: '0'
|
||||
}}
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '20px',
|
||||
backgroundColor: '#FAFAFA'
|
||||
}}
|
||||
>
|
||||
{messages.map((message, index) => renderMessage(message, index))}
|
||||
|
||||
{isLoading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#95A5A6' }}>
|
||||
<div>⏳</div>
|
||||
<div>Thinking...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
{messages.length <= 1 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '15px',
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderTop: '1px solid #ddd'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '10px' }}>
|
||||
Quick actions:
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{quickActions.map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setInputText(action.prompt);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid #ddd',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.backgroundColor = '#E8F4F8';
|
||||
e.target.style.borderColor = '#3498DB';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.backgroundColor = 'white';
|
||||
e.target.style.borderColor = '#ddd';
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
style={{
|
||||
padding: '15px',
|
||||
borderTop: '2px solid #ECF0F1',
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Ask a question... (Enter to send)"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
resize: 'none',
|
||||
minHeight: '60px',
|
||||
maxHeight: '120px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputText.trim() || isLoading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
backgroundColor: !inputText.trim() || isLoading ? '#95A5A6' : '#3498DB',
|
||||
color: 'white',
|
||||
borderRadius: '8px',
|
||||
cursor: !inputText.trim() || isLoading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{isLoading ? '⏳' : '📤'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#95A5A6', marginTop: '8px' }}>
|
||||
Shift+Enter for new line
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpChat;
|
||||
315
services/dashboard/NetworkMap.jsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* NetworkMap Component
|
||||
* Interactive network graph visualization using Cytoscape.js
|
||||
* Displays discovered hosts from nmap scans with OS/device icons
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const NetworkMap = ({ scanId, onNodeClick }) => {
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filterText, setFilterText] = useState('');
|
||||
const cyRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scanId) {
|
||||
fetchHostData(scanId);
|
||||
}
|
||||
}, [scanId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hosts.length > 0 && containerRef.current) {
|
||||
initializeNetwork();
|
||||
}
|
||||
}, [hosts]);
|
||||
|
||||
const fetchHostData = async (scanId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/nmap/hosts?scan_id=${scanId}`);
|
||||
const data = await response.json();
|
||||
setHosts(data.hosts || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching host data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeNetwork = () => {
|
||||
// NOTE: This requires cytoscape.js to be installed: npm install cytoscape
|
||||
// import cytoscape from 'cytoscape';
|
||||
|
||||
// Example initialization (requires actual cytoscape import)
|
||||
/*
|
||||
const cy = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements: buildGraphElements(hosts),
|
||||
style: getNetworkStyle(),
|
||||
layout: {
|
||||
name: 'cose',
|
||||
idealEdgeLength: 100,
|
||||
nodeOverlap: 20,
|
||||
refresh: 20,
|
||||
fit: true,
|
||||
padding: 30,
|
||||
randomize: false,
|
||||
componentSpacing: 100,
|
||||
nodeRepulsion: 400000,
|
||||
edgeElasticity: 100,
|
||||
nestingFactor: 5,
|
||||
gravity: 80,
|
||||
numIter: 1000,
|
||||
initialTemp: 200,
|
||||
coolingFactor: 0.95,
|
||||
minTemp: 1.0
|
||||
}
|
||||
});
|
||||
|
||||
cy.on('tap', 'node', (evt) => {
|
||||
const node = evt.target;
|
||||
const hostData = node.data();
|
||||
if (onNodeClick) {
|
||||
onNodeClick(hostData);
|
||||
}
|
||||
});
|
||||
|
||||
cyRef.current = cy;
|
||||
*/
|
||||
};
|
||||
|
||||
const buildGraphElements = (hosts) => {
|
||||
const elements = [];
|
||||
|
||||
// Add nodes for each host
|
||||
hosts.forEach((host, index) => {
|
||||
elements.push({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: `host-${index}`,
|
||||
label: host.hostname || host.ip,
|
||||
...host,
|
||||
icon: getIconForHost(host)
|
||||
},
|
||||
classes: getNodeClass(host)
|
||||
});
|
||||
});
|
||||
|
||||
// Add edges (connections) - could be inferred from network topology
|
||||
// For now, connect hosts in same subnet
|
||||
const subnets = groupBySubnet(hosts);
|
||||
Object.values(subnets).forEach(subnetHosts => {
|
||||
if (subnetHosts.length > 1) {
|
||||
for (let i = 0; i < subnetHosts.length - 1; i++) {
|
||||
elements.push({
|
||||
group: 'edges',
|
||||
data: {
|
||||
id: `edge-${subnetHosts[i].ip}-${subnetHosts[i + 1].ip}`,
|
||||
source: `host-${hosts.indexOf(subnetHosts[i])}`,
|
||||
target: `host-${hosts.indexOf(subnetHosts[i + 1])}`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
const getIconForHost = (host) => {
|
||||
const osType = (host.os_type || '').toLowerCase();
|
||||
const deviceType = (host.device_type || '').toLowerCase();
|
||||
|
||||
if (deviceType.includes('server')) return '/static/server.svg';
|
||||
if (deviceType.includes('network') || deviceType.includes('router') || deviceType.includes('switch')) {
|
||||
return '/static/network.svg';
|
||||
}
|
||||
if (deviceType.includes('workstation')) return '/static/workstation.svg';
|
||||
|
||||
if (osType.includes('windows')) return '/static/windows.svg';
|
||||
if (osType.includes('linux') || osType.includes('unix')) return '/static/linux.svg';
|
||||
if (osType.includes('mac') || osType.includes('darwin')) return '/static/mac.svg';
|
||||
|
||||
return '/static/unknown.svg';
|
||||
};
|
||||
|
||||
const getNodeClass = (host) => {
|
||||
const deviceType = (host.device_type || '').toLowerCase();
|
||||
if (deviceType.includes('server')) return 'node-server';
|
||||
if (deviceType.includes('network')) return 'node-network';
|
||||
if (deviceType.includes('workstation')) return 'node-workstation';
|
||||
return 'node-unknown';
|
||||
};
|
||||
|
||||
const groupBySubnet = (hosts) => {
|
||||
const subnets = {};
|
||||
hosts.forEach(host => {
|
||||
const subnet = host.ip.split('.').slice(0, 3).join('.');
|
||||
if (!subnets[subnet]) {
|
||||
subnets[subnet] = [];
|
||||
}
|
||||
subnets[subnet].push(host);
|
||||
});
|
||||
return subnets;
|
||||
};
|
||||
|
||||
const getNetworkStyle = () => {
|
||||
return [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'background-color': '#4A90E2',
|
||||
'label': 'data(label)',
|
||||
'text-valign': 'bottom',
|
||||
'text-halign': 'center',
|
||||
'font-size': '12px',
|
||||
'color': '#333',
|
||||
'text-margin-y': 5,
|
||||
'width': 50,
|
||||
'height': 50,
|
||||
'background-image': 'data(icon)',
|
||||
'background-fit': 'contain'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: '.node-server',
|
||||
style: {
|
||||
'background-color': '#4A90E2'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: '.node-network',
|
||||
style: {
|
||||
'background-color': '#16A085'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: '.node-workstation',
|
||||
style: {
|
||||
'background-color': '#5DADE2'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': 2,
|
||||
'line-color': '#95A5A6',
|
||||
'curve-style': 'bezier'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'border-width': 3,
|
||||
'border-color': '#E74C3C'
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const exportToPNG = () => {
|
||||
if (cyRef.current) {
|
||||
const png = cyRef.current.png({ scale: 2, full: true });
|
||||
const link = document.createElement('a');
|
||||
link.href = png;
|
||||
link.download = `network-map-${Date.now()}.png`;
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
const csvContent = [
|
||||
['IP', 'Hostname', 'OS Type', 'Device Type', 'MAC', 'Vendor', 'Open Ports'].join(','),
|
||||
...hosts.map(host => [
|
||||
host.ip,
|
||||
host.hostname || '',
|
||||
host.os_type || '',
|
||||
host.device_type || '',
|
||||
host.mac || '',
|
||||
host.vendor || '',
|
||||
(host.ports || []).map(p => p.port).join(';')
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `network-hosts-${Date.now()}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const filteredHosts = hosts.filter(host => {
|
||||
if (!filterText) return true;
|
||||
const searchLower = filterText.toLowerCase();
|
||||
return (
|
||||
host.ip.includes(searchLower) ||
|
||||
(host.hostname || '').toLowerCase().includes(searchLower) ||
|
||||
(host.os_type || '').toLowerCase().includes(searchLower) ||
|
||||
(host.device_type || '').toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="network-map-container" style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="network-map-toolbar" style={{ padding: '10px', borderBottom: '1px solid #ddd', display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter hosts (IP, hostname, OS, device type)..."
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||
/>
|
||||
<button onClick={exportToPNG} style={{ padding: '8px 16px', cursor: 'pointer', borderRadius: '4px' }}>
|
||||
Export PNG
|
||||
</button>
|
||||
<button onClick={exportToCSV} style={{ padding: '8px 16px', cursor: 'pointer', borderRadius: '4px' }}>
|
||||
Export CSV
|
||||
</button>
|
||||
<span style={{ color: '#666' }}>
|
||||
{filteredHosts.length} host{filteredHosts.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="network-map-canvas"
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>Loading network map...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && hosts.length === 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
color: '#666'
|
||||
}}>
|
||||
<div>No hosts discovered yet</div>
|
||||
<div style={{ fontSize: '14px', marginTop: '10px' }}>Run a network scan to populate the map</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkMap;
|
||||
354
services/dashboard/VoiceControls.jsx
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* VoiceControls Component
|
||||
* Microphone button with hotkey support for voice commands
|
||||
* Visual feedback for listening, processing, and speaking states
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const VoiceControls = ({ onCommand, hotkey = ' ' }) => {
|
||||
const [state, setState] = useState('idle'); // idle, listening, processing, speaking
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [permissionGranted, setPermissionGranted] = useState(false);
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const audioChunksRef = useRef([]);
|
||||
const hotkeyPressedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if browser supports MediaRecorder
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
setError('Voice control not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
// Request microphone permission
|
||||
requestMicrophonePermission();
|
||||
|
||||
// Setup hotkey listener
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === hotkey && !hotkeyPressedRef.current && state === 'idle') {
|
||||
hotkeyPressedRef.current = true;
|
||||
startListening();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e) => {
|
||||
if (e.key === hotkey && hotkeyPressedRef.current) {
|
||||
hotkeyPressedRef.current = false;
|
||||
if (state === 'listening') {
|
||||
stopListening();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
if (mediaRecorderRef.current && state === 'listening') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
};
|
||||
}, [hotkey, state]);
|
||||
|
||||
const requestMicrophonePermission = async () => {
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
setPermissionGranted(true);
|
||||
} catch (err) {
|
||||
setError('Microphone permission denied');
|
||||
setPermissionGranted(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startListening = async () => {
|
||||
if (!permissionGranted) {
|
||||
await requestMicrophonePermission();
|
||||
if (!permissionGranted) return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
await processAudio(audioBlob);
|
||||
|
||||
// Stop all tracks
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setState('listening');
|
||||
setTranscript('');
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error starting recording:', err);
|
||||
setError('Failed to start recording: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const stopListening = () => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
};
|
||||
|
||||
const processAudio = async (audioBlob) => {
|
||||
setState('processing');
|
||||
|
||||
try {
|
||||
// Send audio to backend for transcription
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
|
||||
const response = await fetch('/api/voice/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Transcription failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const transcribedText = data.text || '';
|
||||
|
||||
setTranscript(transcribedText);
|
||||
|
||||
if (transcribedText) {
|
||||
// Parse and route the voice command
|
||||
await routeCommand(transcribedText);
|
||||
} else {
|
||||
setError('No speech detected');
|
||||
setState('idle');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing audio:', err);
|
||||
setError('Failed to process audio: ' + err.message);
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const routeCommand = async (text) => {
|
||||
try {
|
||||
const response = await fetch('/api/voice/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Command routing failed');
|
||||
}
|
||||
|
||||
const commandResult = await response.json();
|
||||
|
||||
// Call parent callback with command result
|
||||
if (onCommand) {
|
||||
onCommand(commandResult);
|
||||
}
|
||||
|
||||
// Check if TTS response is available
|
||||
if (commandResult.speak_response) {
|
||||
await speakResponse(commandResult.speak_response);
|
||||
} else {
|
||||
setState('idle');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error routing command:', err);
|
||||
setError('Failed to execute command: ' + err.message);
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const speakResponse = async (text) => {
|
||||
setState('speaking');
|
||||
|
||||
try {
|
||||
// Try to get TTS audio from backend
|
||||
const response = await fetch('/api/voice/speak', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const audioBlob = await response.blob();
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
|
||||
audio.onended = () => {
|
||||
setState('idle');
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
|
||||
audio.play();
|
||||
} else {
|
||||
// Fallback to browser TTS
|
||||
if ('speechSynthesis' in window) {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.onend = () => setState('idle');
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} else {
|
||||
setState('idle');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error speaking response:', err);
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const getStateColor = () => {
|
||||
switch (state) {
|
||||
case 'listening': return '#27AE60';
|
||||
case 'processing': return '#F39C12';
|
||||
case 'speaking': return '#3498DB';
|
||||
default: return '#95A5A6';
|
||||
}
|
||||
};
|
||||
|
||||
const getStateIcon = () => {
|
||||
switch (state) {
|
||||
case 'listening': return '🎤';
|
||||
case 'processing': return '⏳';
|
||||
case 'speaking': return '🔊';
|
||||
default: return '🎙️';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="voice-controls"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: '10px'
|
||||
}}
|
||||
>
|
||||
{/* Transcript display */}
|
||||
{transcript && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '10px 15px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
|
||||
maxWidth: '300px',
|
||||
fontSize: '14px',
|
||||
color: '#333'
|
||||
}}
|
||||
>
|
||||
<strong>You said:</strong> {transcript}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#E74C3C',
|
||||
color: 'white',
|
||||
padding: '10px 15px',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '300px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mic button */}
|
||||
<button
|
||||
onClick={state === 'idle' ? startListening : stopListening}
|
||||
disabled={state === 'processing' || state === 'speaking'}
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
backgroundColor: getStateColor(),
|
||||
color: 'white',
|
||||
fontSize: '24px',
|
||||
cursor: state === 'idle' || state === 'listening' ? 'pointer' : 'not-allowed',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
transform: state === 'listening' ? 'scale(1.1)' : 'scale(1)',
|
||||
opacity: state === 'processing' || state === 'speaking' ? 0.7 : 1
|
||||
}}
|
||||
title={`Voice command (hold ${hotkey === ' ' ? 'Space' : hotkey})`}
|
||||
>
|
||||
{getStateIcon()}
|
||||
</button>
|
||||
|
||||
{/* Pulsing animation for listening state */}
|
||||
{state === 'listening' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
right: '0',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid #27AE60',
|
||||
animation: 'pulse 1.5s infinite',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hotkey hint */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
Hold {hotkey === ' ' ? 'Space' : hotkey} to talk
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceControls;
|
||||
9
services/dashboard/static/linux.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<circle cx="24" cy="24" r="22" fill="#000000"/>
|
||||
<ellipse cx="24" cy="20" rx="12" ry="14" fill="#FFFFFF"/>
|
||||
<ellipse cx="24" cy="28" rx="10" ry="8" fill="#FDB515"/>
|
||||
<circle cx="20" cy="18" r="2" fill="#000000"/>
|
||||
<circle cx="28" cy="18" r="2" fill="#000000"/>
|
||||
<path d="M 24 22 Q 22 24 24 24 Q 26 24 24 22" fill="none" stroke="#000000" stroke-width="1.5"/>
|
||||
<path d="M 16 26 Q 18 30 24 32 Q 30 30 32 26" fill="none" stroke="#000000" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 554 B |
8
services/dashboard/static/mac.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<circle cx="24" cy="24" r="22" fill="#A2AAAD"/>
|
||||
<path d="M 30 14 Q 28 10 24 10 Q 20 10 18 14 Q 16 18 18 22 Q 20 26 24 26 Q 28 26 30 22 Q 32 18 30 14 Z" fill="#FFFFFF"/>
|
||||
<circle cx="24" cy="16" r="4" fill="#A2AAAD"/>
|
||||
<path d="M 26 10 Q 28 8 30 10" stroke="#FFFFFF" stroke-width="2" fill="none"/>
|
||||
<rect x="22" y="26" width="4" height="8" fill="#FFFFFF"/>
|
||||
<path d="M 18 34 Q 20 36 24 36 Q 28 36 30 34 L 28 32 Q 26 33 24 33 Q 22 33 20 32 Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 557 B |
16
services/dashboard/static/network.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<rect width="48" height="48" fill="#16A085" rx="2"/>
|
||||
<rect x="6" y="18" width="36" height="12" rx="1" fill="#2C3E50"/>
|
||||
<circle cx="10" cy="24" r="1.5" fill="#27AE60"/>
|
||||
<circle cx="14" cy="24" r="1.5" fill="#27AE60"/>
|
||||
<circle cx="18" cy="24" r="1.5" fill="#27AE60"/>
|
||||
<circle cx="22" cy="24" r="1.5" fill="#27AE60"/>
|
||||
<circle cx="26" cy="24" r="1.5" fill="#F39C12"/>
|
||||
<circle cx="30" cy="24" r="1.5" fill="#95A5A6"/>
|
||||
<circle cx="34" cy="24" r="1.5" fill="#95A5A6"/>
|
||||
<circle cx="38" cy="24" r="1.5" fill="#95A5A6"/>
|
||||
<line x1="24" y1="10" x2="24" y2="18" stroke="#ECF0F1" stroke-width="2"/>
|
||||
<line x1="24" y1="30" x2="24" y2="38" stroke="#ECF0F1" stroke-width="2"/>
|
||||
<line x1="10" y1="24" x2="4" y2="24" stroke="#ECF0F1" stroke-width="2"/>
|
||||
<line x1="38" y1="24" x2="44" y2="24" stroke="#ECF0F1" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 925 B |
15
services/dashboard/static/server.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<rect width="48" height="48" fill="#4A90E2" rx="2"/>
|
||||
<rect x="6" y="6" width="36" height="10" rx="1" fill="#2C3E50"/>
|
||||
<rect x="6" y="19" width="36" height="10" rx="1" fill="#34495E"/>
|
||||
<rect x="6" y="32" width="36" height="10" rx="1" fill="#2C3E50"/>
|
||||
<circle cx="10" cy="11" r="1.5" fill="#27AE60"/>
|
||||
<circle cx="14" cy="11" r="1.5" fill="#F39C12"/>
|
||||
<circle cx="10" cy="24" r="1.5" fill="#27AE60"/>
|
||||
<circle cx="14" cy="24" r="1.5" fill="#27AE60"/>
|
||||
<circle cx="10" cy="37" r="1.5" fill="#E74C3C"/>
|
||||
<circle cx="14" cy="37" r="1.5" fill="#F39C12"/>
|
||||
<rect x="18" y="9" width="20" height="4" rx="0.5" fill="#7F8C8D"/>
|
||||
<rect x="18" y="22" width="20" height="4" rx="0.5" fill="#7F8C8D"/>
|
||||
<rect x="18" y="35" width="20" height="4" rx="0.5" fill="#7F8C8D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 864 B |
5
services/dashboard/static/unknown.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<circle cx="24" cy="24" r="22" fill="#95A5A6"/>
|
||||
<circle cx="24" cy="24" r="18" fill="#7F8C8D"/>
|
||||
<text x="24" y="32" font-size="24" font-weight="bold" fill="#ECF0F1" text-anchor="middle" font-family="Arial">?</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 312 B |
7
services/dashboard/static/windows.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<rect width="48" height="48" fill="#0078D4" rx="2"/>
|
||||
<rect x="6" y="6" width="17" height="17" fill="#FFFFFF"/>
|
||||
<rect x="25" y="6" width="17" height="17" fill="#FFFFFF"/>
|
||||
<rect x="6" y="25" width="17" height="17" fill="#FFFFFF"/>
|
||||
<rect x="25" y="25" width="17" height="17" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
9
services/dashboard/static/workstation.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<rect width="48" height="48" fill="#5DADE2" rx="2"/>
|
||||
<rect x="8" y="8" width="32" height="22" rx="1" fill="#34495E"/>
|
||||
<rect x="10" y="10" width="28" height="18" fill="#3498DB"/>
|
||||
<rect x="18" y="30" width="12" height="2" fill="#34495E"/>
|
||||
<rect x="12" y="32" width="24" height="4" rx="1" fill="#2C3E50"/>
|
||||
<circle cx="24" cy="14" r="2" fill="#ECF0F1"/>
|
||||
<rect x="18" y="18" width="12" height="8" rx="1" fill="#ECF0F1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 521 B |