Fix: Add client source files properly
This commit is contained in:
1 parent
17c366dcae
commit
a3aaebab5e
21 files changed
+19337
-1
No files matched your search
@@ -0,0 +1,252 @@
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 1rem 2rem;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.navbar h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resource-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.resource-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.resource-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.resource-url {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-up {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-down {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-unknown {
|
||||
background: #e2e3e5;
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.detail-section h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-top: 1rem;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.incident-badge {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import './App.css';
|
||||
import SettingsWizard from './SettingsWizard';
|
||||
|
||||
function Dashboard() {
|
||||
const [resources, setResources] = useState([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
url: '',
|
||||
type: 'http',
|
||||
check_interval: 60000,
|
||||
timeout: 5000,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
loadResources();
|
||||
const interval = setInterval(loadResources, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadResources = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/dashboard');
|
||||
setResources(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading resources:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await axios.post('/api/resources', formData);
|
||||
setShowModal(false);
|
||||
setFormData({ name: '', url: '', type: 'http', check_interval: 60000, timeout: 5000 });
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
console.error('Error creating resource:', error);
|
||||
alert('Error creating resource');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h2>Resource Monitor Dashboard</h2>
|
||||
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
|
||||
+ Add Resource
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{resources.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No resources yet</h3>
|
||||
<p>Add your first resource to start monitoring</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-grid">
|
||||
{resources.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="resource-card"
|
||||
onClick={() => navigate(`/resource/${resource.id}`)}
|
||||
>
|
||||
<div className="resource-header">
|
||||
<div>
|
||||
<h3 className="resource-name">{resource.name}</h3>
|
||||
<p className="resource-url">{resource.url}</p>
|
||||
</div>
|
||||
<span className={`status-badge status-${resource.status}`}>
|
||||
{resource.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{resource.hasActiveIncident && (
|
||||
<div className="incident-badge">⚠️ Active Incident</div>
|
||||
)}
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat">
|
||||
<p className="stat-value">{resource.uptime}%</p>
|
||||
<p className="stat-label">Uptime (24h)</p>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<p className="stat-value">{resource.avgResponseTime}ms</p>
|
||||
<p className="stat-label">Avg Response</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resource.lastCheck && (
|
||||
<p style={{ marginTop: '1rem', fontSize: '0.75rem', color: '#999' }}>
|
||||
Last checked: {new Date(resource.lastCheck).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Add New Resource</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
placeholder="e.g., ZimaOS Home Server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
required
|
||||
placeholder="https://your-zima.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Check Interval (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.check_interval}
|
||||
onChange={(e) => setFormData({ ...formData, check_interval: parseInt(e.target.value) })}
|
||||
min="10000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Timeout (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.timeout}
|
||||
onChange={(e) => setFormData({ ...formData, timeout: parseInt(e.target.value) })}
|
||||
min="1000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Add Resource
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [resource, setResource] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadResource();
|
||||
const interval = setInterval(loadResource, 5000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const loadResource = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/resources/${id}`);
|
||||
setResource(response.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error loading resource:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (window.confirm('Are you sure you want to delete this resource?')) {
|
||||
try {
|
||||
await axios.delete(`/api/resources/${id}`);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.error('Error deleting resource:', error);
|
||||
alert('Error deleting resource');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnabled = async () => {
|
||||
try {
|
||||
await axios.put(`/api/resources/${id}`, {
|
||||
...resource,
|
||||
enabled: !resource.enabled,
|
||||
});
|
||||
loadResource();
|
||||
} catch (error) {
|
||||
console.error('Error updating resource:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="container">Loading...</div>;
|
||||
if (!resource) return <div className="container">Resource not found</div>;
|
||||
|
||||
const chartData = resource.stats.checks.map((check) => ({
|
||||
time: new Date(check.checked_at).toLocaleTimeString(),
|
||||
responseTime: check.response_time,
|
||||
status: check.status === 'up' ? 1 : 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<Link to="/" className="back-button">← Back to Dashboard</Link>
|
||||
|
||||
<div className="detail-section">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||
<div>
|
||||
<h2>{resource.name}</h2>
|
||||
<p style={{ color: '#666' }}>{resource.url}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button className="btn" onClick={toggleEnabled}>
|
||||
{resource.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={handleDelete}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resource.hasActiveIncident && (
|
||||
<div className="incident-badge">⚠️ Active Incident - Resource is currently DOWN</div>
|
||||
)}
|
||||
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)', marginTop: '2rem' }}>
|
||||
<div className="stat">
|
||||
<p className="stat-value">{resource.stats.uptime}%</p>
|
||||
<p className="stat-label">Uptime (24h)</p>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<p className="stat-value">{resource.stats.avgResponseTime}ms</p>
|
||||
<p className="stat-label">Avg Response</p>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<p className="stat-value">{resource.stats.totalChecks}</p>
|
||||
<p className="stat-label">Total Checks</p>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<p className={`stat-value status-badge status-${resource.lastCheck?.status || 'unknown'}`}>
|
||||
{resource.lastCheck?.status || 'unknown'}
|
||||
</p>
|
||||
<p className="stat-label">Current Status</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h2>Response Time (Last 50 Checks)</h2>
|
||||
<div className="chart-container">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="responseTime" stroke="#667eea" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h2>Recent Checks</h2>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Time</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Status</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'right' }}>Response Time</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resource.stats.checks.slice(-10).reverse().map((check, idx) => (
|
||||
<tr key={idx} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
{new Date(check.checked_at).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<span className={`status-badge status-${check.status}`}>
|
||||
{check.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', textAlign: 'right' }}>
|
||||
{check.response_time ? `${check.response_time}ms` : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
||||
{check.error_message || 'OK'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Navbar() {
|
||||
const [notificationsConfigured, setNotificationsConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkNotifications();
|
||||
}, []);
|
||||
|
||||
const checkNotifications = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/settings');
|
||||
setNotificationsConfigured(response.data.email_enabled || response.data.webhook_enabled);
|
||||
} catch (error) {
|
||||
console.error('Error checking notifications:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<h1>🔍 Resource Monitor</h1>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<Link to="/" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Dashboard</Link>
|
||||
<Link to="/settings" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
Settings
|
||||
{!notificationsConfigured && (
|
||||
<span style={{
|
||||
background: '#ffc107',
|
||||
color: '#000',
|
||||
padding: '0.15rem 0.5rem',
|
||||
borderRadius: '12px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 'bold'
|
||||
}}>SETUP</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{notificationsConfigured && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', opacity: 0.9 }}>
|
||||
<span>🔔</span>
|
||||
<span>Notifications Active</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="App">
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/resource/:id" element={<ResourceDetail />} />
|
||||
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
.settings-wizard {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
color: #666;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.settings-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-box ol {
|
||||
margin: 0.5rem 0 0 1.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin: 0.25rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-box a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.info-box a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info-box pre {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background-color: #667eea;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.test-result.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.test-result.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.test-result strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.test-result p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 1rem 3rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './SettingsWizard.css';
|
||||
|
||||
function SettingsWizard() {
|
||||
const [settings, setSettings] = useState({
|
||||
email_enabled: false,
|
||||
email_host: 'smtp.gmail.com',
|
||||
email_port: 587,
|
||||
email_user: '',
|
||||
email_pass: '',
|
||||
email_from: '',
|
||||
email_to: '',
|
||||
webhook_enabled: false,
|
||||
webhook_url: '',
|
||||
check_interval: 60000,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/settings');
|
||||
setSettings(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await axios.post('/api/settings', settings);
|
||||
alert('Settings saved successfully! You may need to restart the server for changes to take effect.');
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
alert('Error saving settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestEmail = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const response = await axios.post('/api/test-email', {
|
||||
email_host: settings.email_host,
|
||||
email_port: settings.email_port,
|
||||
email_user: settings.email_user,
|
||||
email_pass: settings.email_pass,
|
||||
email_from: settings.email_from,
|
||||
email_to: settings.email_to,
|
||||
});
|
||||
setTestResult({ success: true, message: response.data.message });
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error.response?.data?.error || 'Failed to send test email'
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestWebhook = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const response = await axios.post('/api/test-webhook', {
|
||||
webhook_url: settings.webhook_url,
|
||||
});
|
||||
setTestResult({ success: true, message: response.data.message });
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error.response?.data?.error || 'Failed to send test webhook'
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const emailProviders = [
|
||||
{ name: 'Gmail', host: 'smtp.gmail.com', port: 587 },
|
||||
{ name: 'Outlook', host: 'smtp-mail.outlook.com', port: 587 },
|
||||
{ name: 'Yahoo', host: 'smtp.mail.yahoo.com', port: 587 },
|
||||
{ name: 'Custom', host: '', port: 587 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-wizard">
|
||||
<h2>⚙️ Settings & Notifications</h2>
|
||||
<p className="settings-description">
|
||||
Configure email and webhook notifications to receive alerts when your resources go offline.
|
||||
</p>
|
||||
|
||||
{/* Email Settings */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-header">
|
||||
<h3>📧 Email Notifications</h3>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.email_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, email_enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.email_enabled && (
|
||||
<div className="settings-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Email Provider</label>
|
||||
<select
|
||||
value={emailProviders.find(p => p.host === settings.email_host)?.name || 'Custom'}
|
||||
onChange={(e) => {
|
||||
const provider = emailProviders.find(p => p.name === e.target.value);
|
||||
if (provider) {
|
||||
setSettings({
|
||||
...settings,
|
||||
email_host: provider.host,
|
||||
email_port: provider.port,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{emailProviders.map(p => (
|
||||
<option key={p.name} value={p.name}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>SMTP Host</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.email_host}
|
||||
onChange={(e) => setSettings({ ...settings, email_host: e.target.value })}
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>SMTP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.email_port}
|
||||
onChange={(e) => setSettings({ ...settings, email_port: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Email Username</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings.email_user}
|
||||
onChange={(e) => setSettings({ ...settings, email_user: e.target.value })}
|
||||
placeholder="your-email@gmail.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Email Password / App Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.email_pass}
|
||||
onChange={(e) => setSettings({ ...settings, email_pass: e.target.value })}
|
||||
placeholder="your-password-or-app-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>From Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings.email_from}
|
||||
onChange={(e) => setSettings({ ...settings, email_from: e.target.value })}
|
||||
placeholder="alerts@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>To Email (Alert Recipient)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings.email_to}
|
||||
onChange={(e) => setSettings({ ...settings, email_to: e.target.value })}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<strong>💡 Gmail Setup:</strong>
|
||||
<ol>
|
||||
<li>Enable 2-factor authentication on your Google account</li>
|
||||
<li>Generate an App Password at <a href="https://myaccount.google.com/apppasswords" target="_blank" rel="noopener noreferrer">myaccount.google.com/apppasswords</a></li>
|
||||
<li>Use the app password (not your regular password) above</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestEmail}
|
||||
disabled={testing || !settings.email_user || !settings.email_pass}
|
||||
>
|
||||
{testing ? 'Sending...' : '📨 Send Test Email'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Webhook Settings */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-header">
|
||||
<h3>🔗 Webhook Notifications</h3>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.webhook_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, webhook_enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.webhook_enabled && (
|
||||
<div className="settings-form">
|
||||
<div className="form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.webhook_url}
|
||||
onChange={(e) => setSettings({ ...settings, webhook_url: e.target.value })}
|
||||
placeholder="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
|
||||
/>
|
||||
<small>Receives POST requests with incident data when resources go offline/online</small>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<strong>💡 Webhook Payload Example:</strong>
|
||||
<pre>{JSON.stringify({
|
||||
resource: "ZimaOS Server",
|
||||
url: "https://example.com",
|
||||
status: "down",
|
||||
message: "Resource is DOWN",
|
||||
timestamp: new Date().toISOString()
|
||||
}, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestWebhook}
|
||||
disabled={testing || !settings.webhook_url}
|
||||
>
|
||||
{testing ? 'Sending...' : '🔔 Send Test Webhook'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div className={`test-result ${testResult.success ? 'success' : 'error'}`}>
|
||||
<strong>{testResult.success ? '✅ Success!' : '❌ Error:'}</strong>
|
||||
<p>{testResult.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monitoring Settings */}
|
||||
<div className="settings-section">
|
||||
<h3>⏱️ Default Monitoring Settings</h3>
|
||||
<div className="settings-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Check Interval (milliseconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.check_interval}
|
||||
onChange={(e) => setSettings({ ...settings, check_interval: parseInt(e.target.value) })}
|
||||
min="10000"
|
||||
step="1000"
|
||||
/>
|
||||
<small>Default interval between checks (60000ms = 1 minute)</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Request Timeout (milliseconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.timeout}
|
||||
onChange={(e) => setSettings({ ...settings, timeout: parseInt(e.target.value) })}
|
||||
min="1000"
|
||||
step="1000"
|
||||
/>
|
||||
<small>How long to wait before considering a request failed</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="settings-actions">
|
||||
<button
|
||||
className="btn btn-primary btn-large"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : '💾 Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsWizard;
|
||||
@@ -0,0 +1,18 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
Reference in new issue
Block a user