feat: Add Health Monitoring to Portfolio page
PORTFOLIO HEALTH MONITORING: - Added 'Health' column to portfolio table - Health check button with SSL, DNS, HTTP analysis - Full Health Report Modal with: - DNS Infrastructure (NS, A, MX records) - Website Status (reachable, HTTP status) - SSL Certificate (valid, expiry days) - Signals & Recommendations - Uses quickHealthCheck API for any domain - Status badges: Healthy, Weak, Parked, Critical FEATURE STATUS CHECK: ✅ My Listings (/command/listings): - Create listings with domain verification - DNS-based verification - Public listing pages - Inquiry management ✅ Sniper Alerts (/command/alerts): - Custom filters (TLD, length, price, keywords) - No numbers/hyphens options - Email notification toggle - Test alert functionality - Match history ✅ SEO Juice (/command/seo): - Domain SEO analysis - Domain Authority, Backlinks - Notable links (Wikipedia, Gov, Edu) - Top backlinks list - Tycoon-only feature
This commit is contained in:
@ -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<HealthStatus, {
|
||||
label: string
|
||||
color: string
|
||||
bgColor: string
|
||||
icon: typeof Activity
|
||||
}> = {
|
||||
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<number | null>(null)
|
||||
|
||||
// Health monitoring state
|
||||
const [healthReports, setHealthReports] = useState<Record<string, DomainHealthReport>>({})
|
||||
const [loadingHealth, setLoadingHealth] = useState<Record<string, boolean>>({})
|
||||
const [selectedHealthDomain, setSelectedHealthDomain] = useState<string | null>(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 <Loader2 className="w-4 h-4 text-foreground-muted animate-spin" />
|
||||
}
|
||||
if (report) {
|
||||
const config = healthStatusConfig[report.status]
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelectedHealthDomain(domain.domain)}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium",
|
||||
config.bgColor, config.color
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleHealthCheck(domain.domain)}
|
||||
className="text-xs text-foreground-muted hover:text-accent transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5" />
|
||||
Check
|
||||
</button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
render: (domain) => (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<TableActionButton
|
||||
icon={Shield}
|
||||
onClick={() => handleHealthCheck(domain.domain)}
|
||||
loading={loadingHealth[domain.domain]}
|
||||
title="Health check (SSL, DNS, HTTP)"
|
||||
variant={healthReports[domain.domain] ? 'accent' : 'default'}
|
||||
/>
|
||||
<TableActionButton
|
||||
icon={Sparkles}
|
||||
onClick={() => handleValuate(domain)}
|
||||
@ -583,10 +663,218 @@ export default function PortfolioPage() {
|
||||
) : null}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Health Report Modal */}
|
||||
{selectedHealthDomain && healthReports[selectedHealthDomain] && (
|
||||
<HealthReportModal
|
||||
report={healthReports[selectedHealthDomain]}
|
||||
onClose={() => setSelectedHealthDomain(null)}
|
||||
/>
|
||||
)}
|
||||
</CommandCenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Health Report Modal Component
|
||||
function HealthReportModal({ report, onClose }: { report: DomainHealthReport; onClose: () => void }) {
|
||||
const config = healthStatusConfig[report.status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg bg-background-secondary border border-border/50 rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx("p-2 rounded-lg", config.bgColor)}>
|
||||
<Icon className={clsx("w-5 h-5", config.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-mono font-semibold text-foreground">{report.domain}</h3>
|
||||
<p className={clsx("text-xs font-medium", config.color)}>{config.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-foreground-muted hover:text-foreground transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div className="p-5 border-b border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground-muted">Health Score</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 bg-foreground/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={clsx(
|
||||
"h-full rounded-full transition-all",
|
||||
report.score >= 70 ? "bg-accent" :
|
||||
report.score >= 40 ? "bg-amber-400" : "bg-red-400"
|
||||
)}
|
||||
style={{ width: `${report.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"text-lg font-bold tabular-nums",
|
||||
report.score >= 70 ? "text-accent" :
|
||||
report.score >= 40 ? "text-amber-400" : "text-red-400"
|
||||
)}>
|
||||
{report.score}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check Results */}
|
||||
<div className="p-5 space-y-4">
|
||||
{/* DNS */}
|
||||
{report.dns && (
|
||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
report.dns.has_ns && report.dns.has_a ? "bg-accent" : "bg-red-400"
|
||||
)} />
|
||||
DNS Infrastructure
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={report.dns.has_ns ? "text-accent" : "text-red-400"}>
|
||||
{report.dns.has_ns ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="text-foreground-muted">Nameservers</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={report.dns.has_a ? "text-accent" : "text-red-400"}>
|
||||
{report.dns.has_a ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="text-foreground-muted">A Record</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={report.dns.has_mx ? "text-accent" : "text-foreground-muted"}>
|
||||
{report.dns.has_mx ? '✓' : '—'}
|
||||
</span>
|
||||
<span className="text-foreground-muted">MX Record</span>
|
||||
</div>
|
||||
</div>
|
||||
{report.dns.is_parked && (
|
||||
<p className="mt-2 text-xs text-orange-400">⚠ Parked at {report.dns.parking_provider || 'unknown provider'}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTTP */}
|
||||
{report.http && (
|
||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
report.http.is_reachable && report.http.status_code === 200 ? "bg-accent" :
|
||||
report.http.is_reachable ? "bg-amber-400" : "bg-red-400"
|
||||
)} />
|
||||
Website Status
|
||||
</h4>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className={clsx(
|
||||
report.http.is_reachable ? "text-accent" : "text-red-400"
|
||||
)}>
|
||||
{report.http.is_reachable ? 'Reachable' : 'Unreachable'}
|
||||
</span>
|
||||
{report.http.status_code && (
|
||||
<span className="text-foreground-muted">
|
||||
HTTP {report.http.status_code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{report.http.is_parked && (
|
||||
<p className="mt-2 text-xs text-orange-400">⚠ Parking page detected</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSL */}
|
||||
{report.ssl && (
|
||||
<div className="p-4 bg-foreground/5 rounded-xl">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<span className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
report.ssl.has_certificate && report.ssl.is_valid ? "bg-accent" :
|
||||
report.ssl.has_certificate ? "bg-amber-400" : "bg-foreground-muted"
|
||||
)} />
|
||||
SSL Certificate
|
||||
</h4>
|
||||
<div className="text-xs">
|
||||
{report.ssl.has_certificate ? (
|
||||
<div className="space-y-1">
|
||||
<p className={report.ssl.is_valid ? "text-accent" : "text-red-400"}>
|
||||
{report.ssl.is_valid ? '✓ Valid certificate' : '✗ Certificate invalid/expired'}
|
||||
</p>
|
||||
{report.ssl.days_until_expiry !== undefined && (
|
||||
<p className={clsx(
|
||||
report.ssl.days_until_expiry > 30 ? "text-foreground-muted" :
|
||||
report.ssl.days_until_expiry > 7 ? "text-amber-400" : "text-red-400"
|
||||
)}>
|
||||
Expires in {report.ssl.days_until_expiry} days
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-muted">No SSL certificate</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signals & Recommendations */}
|
||||
{((report.signals?.length || 0) > 0 || (report.recommendations?.length || 0) > 0) && (
|
||||
<div className="space-y-3">
|
||||
{(report.signals?.length || 0) > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Signals</h4>
|
||||
<ul className="space-y-1">
|
||||
{report.signals?.map((signal, i) => (
|
||||
<li key={i} className="text-xs text-foreground flex items-start gap-2">
|
||||
<span className="text-accent mt-0.5">•</span>
|
||||
{signal}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{(report.recommendations?.length || 0) > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-foreground-muted uppercase tracking-wider mb-2">Recommendations</h4>
|
||||
<ul className="space-y-1">
|
||||
{report.recommendations?.map((rec, i) => (
|
||||
<li key={i} className="text-xs text-foreground flex items-start gap-2">
|
||||
<span className="text-amber-400 mt-0.5">→</span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-foreground/5 border-t border-border/30">
|
||||
<p className="text-xs text-foreground-subtle text-center">
|
||||
Checked at {new Date(report.checked_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Modal Component
|
||||
function Modal({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user