Fix: Add client source files properly

This commit is contained in:
nmemmert committed 2025-12-04 10:35:14 -05:00
1 parent 17c366dcae
commit a3aaebab5e
21 files changed
+19337 -1

No files matched your search

+252
View File
@@ -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;
}
+387
View File
@@ -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;
+8
View File
@@ -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();
});
+189
View File
@@ -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;
}
+330
View File
@@ -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;
+18
View File
@@ -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;
}
+17
View File
@@ -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();
+1
View File
@@ -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

+13
View File
@@ -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;
+5
View File
@@ -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';