diff --git a/frontend/src/app/command/portfolio/page.tsx b/frontend/src/app/command/portfolio/page.tsx index cf18beb..f0b1c6b 100644 --- a/frontend/src/app/command/portfolio/page.tsx +++ b/frontend/src/app/command/portfolio/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useStore } from '@/lib/store' -import { api, PortfolioDomain, PortfolioSummary, DomainValuation } from '@/lib/api' +import { api, PortfolioDomain, PortfolioSummary, DomainValuation, DomainHealthReport, HealthStatus } from '@/lib/api' import { CommandCenterLayout } from '@/components/CommandCenterLayout' import { PremiumTable, StatCard, PageContainer, TableActionButton } from '@/components/PremiumTable' import { Toast, useToast } from '@/components/Toast' @@ -22,7 +22,24 @@ import { Briefcase, PiggyBank, ShoppingCart, + Activity, + Shield, + AlertTriangle, } from 'lucide-react' + +// Health status configuration +const healthStatusConfig: Record = { + healthy: { label: 'Healthy', color: 'text-accent', bgColor: 'bg-accent/10', icon: Activity }, + weakening: { label: 'Weak', color: 'text-amber-400', bgColor: 'bg-amber-400/10', icon: AlertTriangle }, + parked: { label: 'Parked', color: 'text-orange-400', bgColor: 'bg-orange-400/10', icon: ShoppingCart }, + critical: { label: 'Critical', color: 'text-red-400', bgColor: 'bg-red-400/10', icon: AlertTriangle }, + unknown: { label: 'Unknown', color: 'text-foreground-muted', bgColor: 'bg-foreground/5', icon: Activity }, +} import clsx from 'clsx' import Link from 'next/link' @@ -44,6 +61,11 @@ export default function PortfolioPage() { const [savingEdit, setSavingEdit] = useState(false) const [processingSale, setProcessingSale] = useState(false) const [refreshingId, setRefreshingId] = useState(null) + + // Health monitoring state + const [healthReports, setHealthReports] = useState>({}) + const [loadingHealth, setLoadingHealth] = useState>({}) + const [selectedHealthDomain, setSelectedHealthDomain] = useState(null) const [addForm, setAddForm] = useState({ domain: '', @@ -183,6 +205,21 @@ export default function PortfolioPage() { } } + const handleHealthCheck = async (domainName: string) => { + if (loadingHealth[domainName]) return + + setLoadingHealth(prev => ({ ...prev, [domainName]: true })) + try { + const report = await api.quickHealthCheck(domainName) + setHealthReports(prev => ({ ...prev, [domainName]: report })) + setSelectedHealthDomain(domainName) + } catch (err: any) { + showToast(err.message || 'Health check failed', 'error') + } finally { + setLoadingHealth(prev => ({ ...prev, [domainName]: false })) + } + } + const handleDelete = async (domain: PortfolioDomain) => { if (!confirm(`Remove ${domain.domain} from your portfolio?`)) return @@ -345,12 +382,55 @@ export default function PortfolioPage() { ) ), }, + { + key: 'health', + header: 'Health', + hideOnMobile: true, + render: (domain) => { + const report = healthReports[domain.domain] + if (loadingHealth[domain.domain]) { + return + } + if (report) { + const config = healthStatusConfig[report.status] + const Icon = config.icon + return ( + + ) + } + return ( + + ) + }, + }, { key: 'actions', header: '', align: 'right', render: (domain) => (
+ handleHealthCheck(domain.domain)} + loading={loadingHealth[domain.domain]} + title="Health check (SSL, DNS, HTTP)" + variant={healthReports[domain.domain] ? 'accent' : 'default'} + /> handleValuate(domain)} @@ -583,10 +663,218 @@ export default function PortfolioPage() { ) : null} )} + + {/* Health Report Modal */} + {selectedHealthDomain && healthReports[selectedHealthDomain] && ( + setSelectedHealthDomain(null)} + /> + )} ) } +// Health Report Modal Component +function HealthReportModal({ report, onClose }: { report: DomainHealthReport; onClose: () => void }) { + const config = healthStatusConfig[report.status] + const Icon = config.icon + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

{report.domain}

+

{config.label}

+
+
+ +
+ + {/* Score */} +
+
+ Health Score +
+
+
= 70 ? "bg-accent" : + report.score >= 40 ? "bg-amber-400" : "bg-red-400" + )} + style={{ width: `${report.score}%` }} + /> +
+ = 70 ? "text-accent" : + report.score >= 40 ? "text-amber-400" : "text-red-400" + )}> + {report.score}/100 + +
+
+
+ + {/* Check Results */} +
+ {/* DNS */} + {report.dns && ( +
+

+ + DNS Infrastructure +

+
+
+ + {report.dns.has_ns ? '✓' : '✗'} + + Nameservers +
+
+ + {report.dns.has_a ? '✓' : '✗'} + + A Record +
+
+ + {report.dns.has_mx ? '✓' : '—'} + + MX Record +
+
+ {report.dns.is_parked && ( +

⚠ Parked at {report.dns.parking_provider || 'unknown provider'}

+ )} +
+ )} + + {/* HTTP */} + {report.http && ( +
+

+ + Website Status +

+
+ + {report.http.is_reachable ? 'Reachable' : 'Unreachable'} + + {report.http.status_code && ( + + HTTP {report.http.status_code} + + )} +
+ {report.http.is_parked && ( +

⚠ Parking page detected

+ )} +
+ )} + + {/* SSL */} + {report.ssl && ( +
+

+ + SSL Certificate +

+
+ {report.ssl.has_certificate ? ( +
+

+ {report.ssl.is_valid ? '✓ Valid certificate' : '✗ Certificate invalid/expired'} +

+ {report.ssl.days_until_expiry !== undefined && ( +

30 ? "text-foreground-muted" : + report.ssl.days_until_expiry > 7 ? "text-amber-400" : "text-red-400" + )}> + Expires in {report.ssl.days_until_expiry} days +

+ )} +
+ ) : ( +

No SSL certificate

+ )} +
+
+ )} + + {/* Signals & Recommendations */} + {((report.signals?.length || 0) > 0 || (report.recommendations?.length || 0) > 0) && ( +
+ {(report.signals?.length || 0) > 0 && ( +
+

Signals

+
    + {report.signals?.map((signal, i) => ( +
  • + + {signal} +
  • + ))} +
+
+ )} + {(report.recommendations?.length || 0) > 0 && ( +
+

Recommendations

+
    + {report.recommendations?.map((rec, i) => ( +
  • + + {rec} +
  • + ))} +
+
+ )} +
+ )} +
+ + {/* Footer */} +
+

+ Checked at {new Date(report.checked_at).toLocaleString()} +

+
+
+
+ ) +} + // Modal Component function Modal({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) { return (