1import { useState, useEffect } from 'react';
2import { useNavigate } from 'react-router-dom';
3import { apiFetch } from '../../lib/api';
4import { useToast } from '../../hooks/useToast';
5import Toast from '../../components/Toast';
7export default function TabHosting() {
8 const navigate = useNavigate();
9 const [wordpressSites, setWordPressSites] = useState([]);
10 const [loading, setLoading] = useState(true);
11 const [error, setError] = useState('');
12 const [customers, setCustomers] = useState([]);
13 const [lastUpdate, setLastUpdate] = useState(null);
14 const [editingSite, setEditingSite] = useState(null);
15 const [assignmentData, setAssignmentData] = useState({
19 const { toasts, showToast, hideToast } = useToast();
22 fetchWordPressSites();
26 const fetchWordPressSites = async () => {
30 // Fetch monitoring data for self-hosted sites
31 const monitoringRes = await apiFetch('/wordpress/monitoring/latest');
32 if (monitoringRes.ok) {
33 const monitoringData = await monitoringRes.json();
34 if (monitoringData.reports && monitoringData.reports.length > 0) {
35 const latestReport = monitoringData.reports[0];
36 if (latestReport && latestReport.sites) {
37 const sites = latestReport.sites.map(site => ({
40 server_ip: latestReport.server_ip,
41 server_hostname: latestReport.server_hostname,
42 last_checked: latestReport.timestamp,
43 last_scan: latestReport.last_scan
45 setWordPressSites(sites);
46 setLastUpdate(latestReport.received_at || latestReport.timestamp);
50 setError('Failed to fetch WordPress sites');
53 console.error('Error fetching WordPress sites:', err);
54 setError(err.message || 'Failed to load WordPress sites');
60 const fetchCustomers = async () => {
62 const res = await apiFetch('/customers');
64 const data = await res.json();
65 setCustomers(data.customers || []);
68 console.error('Error fetching customers:', err);
72 const handleAssignSite = (site) => {
75 customerId: site.customer_id || '',
76 notes: site.notes || ''
80 const handleSaveAssignment = async () => {
81 if (!editingSite) return;
84 const res = await apiFetch(`/wordpress/sites/${editingSite.domain}/assign`, {
86 headers: { 'Content-Type': 'application/json' },
87 body: JSON.stringify(assignmentData)
91 showToast('Site assignment updated successfully', 'success', 3000);
93 fetchWordPressSites();
95 const errorData = await res.json();
96 showToast(errorData.error || 'Failed to update assignment', 'error', 5000);
99 console.error('Error saving assignment:', err);
100 showToast(err.message || 'Failed to save assignment', 'error', 5000);
104 const getStatusColor = (status) => {
108 'timeout': 'warning',
110 'unknown': 'secondary'
112 return statusMap[status?.toLowerCase()] || 'secondary';
115 const handleRefresh = () => {
116 fetchWordPressSites();
117 showToast('Refreshing WordPress sites...', 'info', 2000);
120 const errorData = await res.json();
121 setError(errorData.error || 'Failed to organize apps');
124 console.error('Error organizing apps:', err);
125 setError(err.message || 'Failed to organize apps');
127 setOrganizing(false);
131 const handleWordPressInputChange = (e) => {
132 const { name, value } = e.target;
133 setWordpressFormData(prev => ({
139 const handleWordPressSubmit = async (e) => {
141 setCreatingWordPress(true);
144 'Creating WordPress site... This may take a few minutes. Please wait.',
149 const res = await apiFetch('/wordpress/create', {
151 headers: { 'Content-Type': 'application/json' },
152 body: JSON.stringify(wordpressFormData)
155 const data = await res.json();
156 setWpDeploymentId(data.deploymentId || null);
157 setWpProgressVisible(true);
159 `WordPress site "${wordpressFormData.siteName}" is being deployed! This can take 5-10 minutes.`,
163 setShowWordPressForm(false);
164 setWordpressFormData({ siteName: '', customerId: '', domain: '' });
166 fetchWordPressSites();
170 const errorData = await res.json();
171 const errorMessage = errorData.error || 'Failed to create WordPress site';
172 setError(errorMessage);
173 showToast(errorMessage, 'error', 10000);
176 console.error('Error creating WordPress site:', err);
177 const errorMessage = err.message || 'Failed to create WordPress site';
178 setError(errorMessage);
179 showToast(errorMessage, 'error', 10000);
181 setCreatingWordPress(false);
185 const handleCreateClick = (type) => {
186 setShowCreateDropdown(false);
187 if (type === 'wordpress') {
188 setShowWordPressForm(true);
189 } else if (type === 'nodejs') {
190 alert('Node.js app creation coming soon!');
194 const getStatusColor = (status) => {
196 'running': 'success',
199 'stopped': 'secondary'
201 return statusMap[status?.toLowerCase()] || 'secondary';
204 const formatTime = (date) => {
205 if (!date) return '';
206 const now = new Date();
207 const then = new Date(date);
208 const diff = Math.floor((now - then) / 1000);
210 if (diff < 60) return `${diff} seconds ago`;
211 if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`;
212 if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
213 return `${Math.floor(diff / 86400)} days ago`;
218 <div style={{ padding: '40px', textAlign: 'center' }}>
219 <div className="spinner"></div>
226 {/* WordPress Deployment Progress Modal */}
227 {wpProgressVisible && wpProgress && (
228 <div style={{ position: 'fixed', top: 80, left: 0, right: 0, zIndex: 2000, display: 'flex', justifyContent: 'center' }}>
229 <div style={{ background: '#fff', border: '1px solid #ccc', borderRadius: 8, padding: 24, minWidth: 340, boxShadow: '0 4px 16px rgba(0,0,0,0.12)' }}>
230 <h4 style={{ marginBottom: 12 }}>WordPress Deployment Progress</h4>
231 <div style={{ marginBottom: 8 }}>
232 <b>Step:</b> {wpProgress.step}
234 <div style={{ marginBottom: 8 }}>
235 <b>Status:</b> {wpProgress.status}
237 <div style={{ marginBottom: 8 }}>
238 <b>Message:</b> {wpProgress.message}
240 {wpProgress.error && (
241 <div style={{ color: 'red', marginBottom: 8 }}>
242 <b>Error:</b> {wpProgress.error}
243 {wpProgress.errorLog && (
245 background: '#fff0f0',
253 whiteSpace: 'pre-wrap',
254 wordBreak: 'break-all'
255 }}>{wpProgress.errorLog}</pre>
259 <div style={{ height: 8, background: '#eee', borderRadius: 4, margin: '16px 0' }}>
261 width: wpProgress.status === 'success' ? '100%' : wpProgress.status === 'error' ? '100%' :
262 wpProgress.step === 'starting' ? '5%' :
263 wpProgress.step === 'credentials' ? '15%' :
264 wpProgress.step === 'database' ? '35%' :
265 wpProgress.step === 'spaces' ? '50%' :
266 wpProgress.step === 'project' ? '65%' :
267 wpProgress.step === 'app' ? '85%' : '90%',
268 background: wpProgress.status === 'error' ? '#e74c3c' : '#3498db',
269 height: 8, borderRadius: 4, transition: 'width 0.5s'
272 {wpProgress.status === 'success' && (
273 <div style={{ color: 'green', marginTop: 8 }}>
274 <b>Deployment completed!</b>
275 {wpProgress.siteUrl && (
276 <div style={{ marginTop: 12 }}>
277 <b>WordPress URL:</b> <a href={wpProgress.siteUrl} target="_blank" rel="noopener noreferrer">{wpProgress.siteUrl}</a>
280 {wpProgress.credentials && (
281 <div style={{ marginTop: 12 }}>
283 <div style={{ marginTop: 4, fontFamily: 'monospace', fontSize: 14 }}>
284 Username: {wpProgress.credentials.username}<br />
285 Password: {wpProgress.credentials.password}
291 {wpProgress.status === 'error' && (
292 <div style={{ color: 'red', marginTop: 8 }}>
293 <b>Deployment failed.</b>
299 {/* ...existing code... */}
300 <div style={{ padding: '20px' }}>
301 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
303 <h2>Web Hosting & WordPress</h2>
304 <p style={{ color: 'var(--text-secondary)', marginTop: '8px' }}>
305 Manage your DigitalOcean App Platform apps and WordPress sites
308 <div style={{ display: 'flex', gap: '12px' }}>
309 <div style={{ position: 'relative' }} ref={dropdownRef}>
311 onClick={() => setShowCreateDropdown(!showCreateDropdown)}
312 className="btn btn-primary"
313 style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
315 <span className="material-symbols-outlined">add</span>
317 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>
318 {showCreateDropdown ? 'expand_less' : 'expand_more'}
321 {showCreateDropdown && (
323 position: 'absolute',
327 background: 'var(--card-bg, #ffffff)',
328 backgroundColor: 'var(--card-bg, #ffffff)',
329 border: '1px solid var(--border)',
331 boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
337 onClick={() => handleCreateClick('wordpress')}
340 padding: '12px 16px',
346 alignItems: 'center',
348 borderBottom: '1px solid var(--border)'
350 onMouseEnter={(e) => e.target.style.background = 'var(--hover-bg)'}
351 onMouseLeave={(e) => e.target.style.background = 'none'}
353 <span className="material-symbols-outlined" style={{ color: 'var(--primary)' }}>
357 <div style={{ fontWeight: '500' }}>WordPress Site</div>
358 <div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
359 Full WordPress installation
364 onClick={() => handleCreateClick('nodejs')}
367 padding: '12px 16px',
373 alignItems: 'center',
376 onMouseEnter={(e) => e.target.style.background = 'var(--hover-bg)'}
377 onMouseLeave={(e) => e.target.style.background = 'none'}
379 <span className="material-symbols-outlined" style={{ color: 'var(--success)' }}>
383 <div style={{ fontWeight: '500' }}>Node.js App</div>
384 <div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
385 Custom Node.js application
395 className="btn btn-primary"
396 style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
398 <span className="material-symbols-outlined" style={{ animation: syncing ? 'spin 1s linear infinite' : 'none' }}>
401 {syncing ? 'Syncing...' : 'Sync Now'}
406 onClick={handleRenameAll}
409 style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
410 title="Add wordpress- or nodejs- prefix to all app names"
412 <span className="material-symbols-outlined">
415 {renaming ? 'Renaming...' : 'Rename Apps'}
418 onClick={handleOrganize}
419 disabled={organizing}
421 style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
422 title="Organize apps into WordPress Projects and Node.js Projects folders"
424 <span className="material-symbols-outlined">
427 {organizing ? 'Organizing...' : 'Organize Apps'}
434 <div className="card" style={{ padding: '16px', marginBottom: '20px', background: 'var(--info-bg)', border: '1px solid var(--info-border)' }}>
435 <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
436 <span className="material-symbols-outlined" style={{ color: 'var(--info-text)' }}>
439 <div style={{ fontSize: '14px', color: 'var(--info-text)' }}>
440 Data refreshes every 5 minutes from DigitalOcean.
442 <span style={{ marginLeft: '8px', opacity: 0.8 }}>
443 Last synced {formatTime(lastSync)}
451 <div className="card" style={{ padding: '16px', marginBottom: '20px', background: 'var(--error-bg)', border: '1px solid var(--error-border)' }}>
452 <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
453 <span className="material-symbols-outlined" style={{ color: 'var(--error-text)' }}>
456 <span style={{ fontSize: '14px', color: 'var(--error-text)' }}>{error}</span>
461 {/* WordPress Create Form */}
462 {showWordPressForm && (
463 <div className="card" style={{ padding: '24px', marginBottom: '20px' }}>
464 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
465 <h3 style={{ margin: 0 }}>Create New WordPress Site</h3>
467 onClick={() => setShowWordPressForm(false)}
469 style={{ padding: '4px 8px' }}
471 <span className="material-symbols-outlined">close</span>
476 padding: '12px 16px',
477 background: 'var(--info-bg)',
478 border: '1px solid var(--info-border)',
480 marginBottom: '20px',
482 color: 'var(--info-text)'
484 <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
485 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>info</span>
486 <span>Database and Spaces credentials are configured globally in Settings (Root Tenant only)</span>
490 <form onSubmit={handleWordPressSubmit}>
491 <div style={{ display: 'grid', gap: '16px', gridTemplateColumns: '1fr 1fr' }}>
493 <label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
499 value={wordpressFormData.siteName}
500 onChange={handleWordPressInputChange}
502 placeholder="my-awesome-site"
503 style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: '4px' }}
505 <small style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
506 Lowercase letters, numbers, and hyphens only
511 <label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
516 value={wordpressFormData.customerId}
517 onChange={handleWordPressInputChange}
518 style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: '4px' }}
520 <option value="">Select customer (optional)</option>
521 {customers.map(customer => (
522 <option key={customer.customer_id} value={customer.customer_id}>
530 <label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
536 value={wordpressFormData.domain}
537 onChange={handleWordPressInputChange}
538 placeholder="example.com"
539 style={{ width: '100%', padding: '8px 12px', border: '1px solid var(--border)', borderRadius: '4px' }}
541 <small style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
542 Leave empty to use DO App Platform domain
547 <div style={{ marginTop: '24px', display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
550 onClick={() => setShowWordPressForm(false)}
557 disabled={creatingWordPress}
558 className="btn btn-primary"
559 style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
561 {creatingWordPress ? (
563 <span className="spinner" style={{ width: '16px', height: '16px' }}></span>
568 <span className="material-symbols-outlined">rocket_launch</span>
569 Create WordPress Site
578 {apps.length === 0 ? (
579 <div className="card" style={{ padding: '60px 20px', textAlign: 'center' }}>
580 <span className="material-symbols-outlined" style={{ fontSize: '64px', marginBottom: '16px', display: 'block', opacity: 0.5, color: 'var(--text-secondary)' }}>
583 <p style={{ color: 'var(--text-secondary)' }}>No apps found</p>
584 <p style={{ fontSize: '14px', color: 'var(--text-tertiary)', marginTop: '8px' }}>
585 Deploy an app to DigitalOcean App Platform to get started
589 <div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
591 <div key={app.app_id} className="card">
592 <div style={{ padding: '16px', borderBottom: '1px solid var(--border)' }}>
593 <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
600 color: 'var(--primary)',
601 textDecoration: 'none'
603 onClick={() => navigate(`/hosting/apps/${app.app_id}`)}
604 onMouseOver={(e) => e.target.style.textDecoration = 'underline'}
605 onMouseOut={(e) => e.target.style.textDecoration = 'none'}
609 <p style={{ fontSize: '14px', color: 'var(--text-secondary)' }}>
610 {app.customer_name || 'Unassigned'}
613 <span className={`badge badge-${getStatusColor(app.status)}`}>
618 <div style={{ padding: '16px' }}>
619 <dl style={{ display: 'grid', gap: '12px', fontSize: '14px' }}>
621 <dt style={{ color: 'var(--text-secondary)', marginBottom: '4px' }}>Region</dt>
622 <dd style={{ fontWeight: '500' }}>{app.region}</dd>
624 {app.default_ingress && (
626 <dt style={{ color: 'var(--text-secondary)', marginBottom: '4px' }}>URL</dt>
627 <dd style={{ fontWeight: '500' }}>
629 href={`https://${app.default_ingress}`}
631 rel="noopener noreferrer"
632 style={{ color: 'var(--primary)', textDecoration: 'none', wordBreak: 'break-all' }}
633 onMouseOver={(e) => e.target.style.textDecoration = 'underline'}
634 onMouseOut={(e) => e.target.style.textDecoration = 'none'}
636 {app.default_ingress}
641 {app.live_domain && (
643 <dt style={{ color: 'var(--text-secondary)', marginBottom: '4px' }}>Custom Domain</dt>
644 <dd style={{ fontWeight: '500' }}>
646 href={`https://${app.live_domain}`}
648 rel="noopener noreferrer"
649 style={{ color: 'var(--primary)', textDecoration: 'none', wordBreak: 'break-all' }}
650 onMouseOver={(e) => e.target.style.textDecoration = 'underline'}
651 onMouseOut={(e) => e.target.style.textDecoration = 'none'}
660 <dt style={{ color: 'var(--text-secondary)', marginBottom: '4px' }}>Tier</dt>
661 <dd style={{ fontWeight: '500', textTransform: 'capitalize' }}>{app.tier}</dd>
671 {/* WordPress Sites Section */}
672 {wordpressSites.length > 0 && (
674 <div style={{ marginTop: '40px', marginBottom: '20px' }}>
675 <h3 style={{ fontSize: '20px', marginBottom: '8px' }}>WordPress Sites</h3>
676 <p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
677 Managed WordPress installations
680 <div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}>
681 {wordpressSites.map((site) => (
682 <div key={site.app_id || site.site_id || site.domain} className="card">
683 <div style={{ padding: '16px', borderBottom: '1px solid var(--border)' }}>
684 <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '12px' }}>
686 <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
687 <span className="material-symbols-outlined" style={{ fontSize: '20px', color: 'var(--primary)' }}>
690 <h3 style={{ margin: 0 }}>{site.name || site.domain}</h3>
692 {site.customer_name && (
693 <p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: 0 }}>
697 {site.is_self_hosted && (
698 <p style={{ fontSize: '12px', color: 'var(--text-secondary)', margin: '4px 0 0 0' }}>
699 Self-Hosted • {site.server_ip}
703 <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
704 <span className={`badge badge-${getStatusColor(site.status)}`}>
705 {site.status || 'unknown'}
707 {site.malware_issues > 0 && (
708 <span className="badge badge-danger" style={{ fontSize: '11px' }}>
709 {site.malware_issues} Security Issue{site.malware_issues !== 1 ? 's' : ''}
715 {(site.url || site.domain) && (
717 href={site.url || `https://${site.domain}`}
719 rel="noopener noreferrer"
722 color: 'var(--primary)',
723 textDecoration: 'none',
725 alignItems: 'center',
729 <span>{site.url || site.domain}</span>
730 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>
737 <div style={{ padding: '16px' }}>
738 <div style={{ display: 'grid', gap: '8px', fontSize: '13px' }}>
739 {site.is_self_hosted ? (
741 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
742 <span style={{ color: 'var(--text-secondary)' }}>WordPress:</span>
743 <span>{site.wp_version?.trim() || 'Unknown'}</span>
745 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
746 <span style={{ color: 'var(--text-secondary)' }}>Plugins:</span>
747 <span>{site.plugin_count || 0}</span>
749 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
750 <span style={{ color: 'var(--text-secondary)' }}>Disk Usage:</span>
751 <span>{site.disk_usage || 'N/A'}</span>
753 {site.last_checked && (
754 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
755 <span style={{ color: 'var(--text-secondary)' }}>Last Checked:</span>
756 <span style={{ fontSize: '12px' }}>
757 {new Date(site.last_checked).toLocaleString()}
764 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
765 <span style={{ color: 'var(--text-secondary)' }}>Region:</span>
766 <span>{site.region || 'syd1'}</span>
768 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
769 <span style={{ color: 'var(--text-secondary)' }}>Created:</span>
770 <span>{new Date(site.created_at).toLocaleDateString()}</span>
773 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
774 <span style={{ color: 'var(--text-secondary)' }}>App ID:</span>
775 <span style={{ fontFamily: 'monospace', fontSize: '11px' }}>
776 {site.app_id.substring(0, 12)}...
784 <div style={{ marginTop: '16px', display: 'flex', gap: '8px' }}>
785 {site.is_self_hosted ? (
788 className="btn btn-sm btn-primary"
789 onClick={() => window.open(`https://${site.domain}/wp-admin`, '_blank')}
790 style={{ flex: 1, fontSize: '12px' }}
792 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>
797 {site.malware_issues > 0 && (
799 className="btn btn-sm"
800 onClick={() => alert(`Security Issues:\n- ${site.malware_issues} suspicious files detected\n\nCheck server logs at:\n/var/log/wordpress-security-alerts.log`)}
801 style={{ fontSize: '12px', color: 'var(--danger)' }}
803 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>
812 className="btn btn-sm"
813 onClick={() => window.open(`https://cloud.digitalocean.com/apps/${site.app_id}`, '_blank')}
814 disabled={!site.app_id}
815 style={{ flex: 1, fontSize: '12px' }}
817 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>
825 className="btn btn-sm"
826 onClick={() => fetchWordPressSites()}
827 style={{ fontSize: '12px' }}
829 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>
841 {/* Toast Notifications */}
842 {toasts.map(toast => (
845 message={toast.message}
847 duration={toast.duration}
848 onClose={() => hideToast(toast.id)}