Files
avbeat-com/src/app/admin/content-status/page.tsx

515 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface NodeStatus {
name: string;
online: boolean;
queueDepth: number;
latencyMs: number;
host: string;
port: number;
}
interface PipelineStatus {
generatedToday: number;
siteBreakdown: Record<string, number>;
queueDepth: number;
pressReleasesRemaining: number;
thinContentRemaining: number;
missingSeoRemaining: number;
nodes: NodeStatus[];
timestamp: string;
}
interface AuditSummary {
totalArticles: number;
totalPublished: number;
thinContentCount: number;
missingFeaturedImageCount: number;
missingSeoAnyCount: number;
pressReleaseCount: number;
averageWordCount: number;
}
interface LaunchChecklist {
zeroPagesWithMissingFeaturedImages: boolean;
zeroPagesWithThinContent: boolean;
zeroPagesMissingSeoMeta: boolean;
zeroUnrewrittenPressReleases: boolean;
}
// ─── Site labels ──────────────────────────────────────────────────────────────
const SITE_LABELS: Record<string, { label: string; color: string; target: string }> = {
'broadcast-beat': { label: 'AV Beat', color: 'text-blue-400', target: '1525/day' },
'av-beat': { label: 'AV Beat', color: 'text-purple-400', target: '1015/day' },
'backlot-beat': { label: 'Backlot Beat', color: 'text-amber-400', target: '1015/day' },
'broadcast-engineering': { label: 'BroadcastEngineering.com', color: 'text-green-400', target: '1015/day' },
};
// ─── Sub-components ───────────────────────────────────────────────────────────
function NodeCard({ node }: { node: NodeStatus }) {
return (
<div className={`bg-[#111] border rounded-lg p-4 flex flex-col gap-2 ${node.online ? 'border-green-500/30' : 'border-red-500/30'}`}>
<div className="flex items-center justify-between">
<span className="text-sm font-display font-bold text-white">{node.name}</span>
<span className={`text-xs font-body font-semibold px-2 py-0.5 rounded-full ${node.online ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{node.online ? 'ONLINE' : 'OFFLINE'}
</span>
</div>
<p className="text-xs text-[#666] font-body">{node.host}:{node.port}</p>
{node.online && (
<div className="flex gap-4 mt-1">
<div>
<p className="text-xs text-[#555] font-body">Queue</p>
<p className={`text-sm font-display font-bold ${node.queueDepth > 10 ? 'text-amber-400' : 'text-green-400'}`}>
{node.queueDepth === 999 ? '—' : node.queueDepth}
</p>
</div>
<div>
<p className="text-xs text-[#555] font-body">Latency</p>
<p className="text-sm font-display font-bold text-white">{node.latencyMs}ms</p>
</div>
</div>
)}
</div>
);
}
function StatCard({
label,
value,
sub,
accent,
good,
}: {
label: string;
value: number | string;
sub?: string;
accent: string;
good?: boolean;
}) {
return (
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex flex-col gap-2">
<p className={`text-2xl font-display font-bold ${accent}`}>{value}</p>
<p className="text-sm text-[#888] font-body">{label}</p>
{sub && <p className="text-xs text-[#555] font-body">{sub}</p>}
{good !== undefined && (
<span className={`text-xs font-body font-semibold mt-1 ${good ? 'text-green-400' : 'text-amber-400'}`}>
{good ? '✓ Launch ready' : '⚠ Action needed'}
</span>
)}
</div>
);
}
function ChecklistItem({ label, done }: { label: string; done: boolean }) {
return (
<div className={`flex items-center gap-3 p-3 rounded-lg border ${done ? 'border-green-500/20 bg-green-500/5' : 'border-amber-500/20 bg-amber-500/5'}`}>
<span className={`text-lg ${done ? 'text-green-400' : 'text-amber-400'}`}>{done ? '✓' : '○'}</span>
<span className={`text-sm font-body ${done ? 'text-green-300' : 'text-amber-300'}`}>{label}</span>
</div>
);
}
// ─── Generate Controls ────────────────────────────────────────────────────────
type RMPSite = 'broadcast-beat' | 'av-beat' | 'backlot-beat' | 'broadcast-engineering';
function GeneratePanel({ onGenerated }: { onGenerated: () => void }) {
const [site, setSite] = useState<RMPSite>('broadcast-beat');
const [topic, setTopic] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleGenerate = async () => {
setLoading(true);
setResult(null);
setError(null);
try {
const res = await fetch('/api/content/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ site, topic: topic.trim() || undefined }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Generation failed');
setResult(`✓ Generated: "${data.article?.title}" via ${data.article?.node} (${data.article?.wordCount} words)`);
onGenerated();
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleSEOBatch = async () => {
setLoading(true);
setResult(null);
setError(null);
try {
const res = await fetch('/api/content/seo-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ site }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'SEO batch failed');
setResult(`✓ SEO meta updated: ${data.updated} articles. Remaining: ${data.remaining}`);
onGenerated();
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
return (
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex flex-col gap-4">
<h3 className="text-sm font-display font-bold text-white">Manual Controls</h3>
<div className="flex flex-col gap-3">
<div>
<label className="text-xs text-[#666] font-body mb-1 block">Site</label>
<select
value={site}
onChange={(e) => setSite(e.target.value as RMPSite)}
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-sm text-white font-body focus:outline-none focus:border-[#ffb800]"
>
{Object.entries(SITE_LABELS).map(([key, val]) => (
<option key={key} value={key}>{val.label}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-[#666] font-body mb-1 block">Topic (optional)</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="e.g. ATSC 3.0 deployment update"
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-sm text-white font-body placeholder-[#444] focus:outline-none focus:border-[#ffb800]"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleGenerate}
disabled={loading}
className="flex-1 bg-[#ffb800] hover:bg-[#d99700] disabled:opacity-50 text-white text-sm font-body font-semibold py-2 px-4 rounded-lg transition-colors"
>
{loading ? 'Generating…' : 'Generate Article'}
</button>
<button
onClick={handleSEOBatch}
disabled={loading}
className="flex-1 bg-[#1a1a1a] hover:bg-[#252525] disabled:opacity-50 border border-[#333] text-white text-sm font-body font-semibold py-2 px-4 rounded-lg transition-colors"
>
{loading ? 'Running…' : 'Batch SEO Meta'}
</button>
</div>
{result && <p className="text-xs text-green-400 font-body bg-green-500/10 border border-green-500/20 rounded-lg p-2">{result}</p>}
{error && <p className="text-xs text-red-400 font-body bg-red-500/10 border border-red-500/20 rounded-lg p-2">{error}</p>}
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function ContentStatusPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [status, setStatus] = useState<PipelineStatus | null>(null);
const [audit, setAudit] = useState<AuditSummary | null>(null);
const [checklist, setChecklist] = useState<LaunchChecklist | null>(null);
const [loadingData, setLoadingData] = useState(true);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const fetchAll = useCallback(async () => {
try {
const [statusRes, auditRes] = await Promise.all([
fetch('/api/content/pipeline-status'),
fetch('/api/content/audit'),
]);
if (statusRes.ok) {
const s = await statusRes.json();
setStatus(s);
}
if (auditRes.ok) {
const a = await auditRes.json();
setAudit(a.summary);
setChecklist(a.launchChecklist);
}
setLastRefresh(new Date());
} catch {
// silent
} finally {
setLoadingData(false);
}
}, []);
useEffect(() => {
if (!loading && !user) router.push('/login?next=' + encodeURIComponent(window.location.pathname));
}, [user, loading, router]);
useEffect(() => {
if (user) fetchAll();
}, [user, fetchAll]);
// Auto-refresh every 30 seconds
useEffect(() => {
if (!autoRefresh || !user) return;
const interval = setInterval(fetchAll, 30_000);
return () => clearInterval(interval);
}, [autoRefresh, user, fetchAll]);
if (loading || loadingData) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-[#ffb800] border-t-transparent rounded-full animate-spin" />
<p className="text-[#555] text-sm font-body">Loading content status</p>
</div>
</div>
);
}
if (!user) return null;
const allNodesOffline = status?.nodes.every((n) => !n.online) ?? false;
const launchReady = checklist
? Object.values(checklist).every(Boolean)
: false;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
{/* Header */}
<div className="border-b border-[#1a1a1a] bg-[#0d0d0d] px-6 py-4">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin" className="text-[#555] hover:text-white transition-colors text-sm font-body">
Admin
</Link>
<span className="text-[#333]">/</span>
<h1 className="text-lg font-display font-bold text-white">Content Pipeline Status</h1>
{allNodesOffline && (
<span className="text-xs font-body font-semibold px-2 py-0.5 rounded-full bg-red-500/15 text-red-400 animate-pulse">
ALL NODES OFFLINE
</span>
)}
</div>
<div className="flex items-center gap-3">
{lastRefresh && (
<span className="text-xs text-[#555] font-body">
Updated {lastRefresh.toLocaleTimeString()}
</span>
)}
<button
onClick={() => setAutoRefresh((v) => !v)}
className={`text-xs font-body px-3 py-1.5 rounded-lg border transition-colors ${
autoRefresh
? 'border-green-500/30 text-green-400 bg-green-500/10' :'border-[#333] text-[#666] bg-[#111]'
}`}
>
{autoRefresh ? '⟳ Auto-refresh ON' : '⟳ Auto-refresh OFF'}
</button>
<button
onClick={fetchAll}
className="text-xs font-body px-3 py-1.5 rounded-lg border border-[#333] text-white bg-[#111] hover:bg-[#1a1a1a] transition-colors"
>
Refresh Now
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-8 flex flex-col gap-8">
{/* AI Node Status */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">AI Node Status</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{(status?.nodes || [
{ name: 'AI_001', online: false, queueDepth: 0, latencyMs: 0, host: '68.248.192.64', port: 8765 },
{ name: 'AI_002', online: false, queueDepth: 0, latencyMs: 0, host: '68.248.192.64', port: 8766 },
]).map((node) => (
<NodeCard key={node.name} node={node} />
))}
</div>
{allNodesOffline && (
<div className="mt-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-sm text-red-400 font-body">
Both AI nodes are offline. New generation jobs are being queued and will retry automatically every 5 minutes.
</p>
</div>
)}
</section>
{/* Today's Pipeline */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">Today's Pipeline</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard
label="Articles Generated Today"
value={status?.generatedToday ?? ''}
accent="text-blue-400"
sub="All sites combined"
/>
<StatCard
label="Queue Depth"
value={status?.queueDepth ?? ''}
accent={status && status.queueDepth > 10 ? 'text-amber-400' : 'text-green-400'}
sub="Jobs pending across nodes"
/>
<StatCard
label="Press Releases Remaining"
value={status?.pressReleasesRemaining ?? ''}
accent={status && status.pressReleasesRemaining > 0 ? 'text-amber-400' : 'text-green-400'}
good={status?.pressReleasesRemaining === 0}
/>
<StatCard
label="SEO Meta Missing"
value={status?.missingSeoRemaining ?? ''}
accent={status && status.missingSeoRemaining > 0 ? 'text-amber-400' : 'text-green-400'}
good={status?.missingSeoRemaining === 0}
/>
</div>
</section>
{/* Per-Site Breakdown */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">Per-Site Breakdown (Today)</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.entries(SITE_LABELS).map(([key, cfg]) => {
const count = status?.siteBreakdown?.[key] ?? 0;
return (
<div key={key} className="bg-[#111] border border-[#252525] rounded-lg p-4 flex flex-col gap-2">
<p className={`text-xs font-body font-semibold ${cfg.color}`}>{cfg.label}</p>
<p className="text-3xl font-display font-bold text-white">{count}</p>
<p className="text-xs text-[#555] font-body">Target: {cfg.target}</p>
</div>
);
})}
</div>
</section>
{/* Content Audit Summary */}
{audit && (
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">Content Audit</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard label="Total Articles" value={audit.totalArticles} accent="text-white" />
<StatCard label="Published" value={audit.totalPublished} accent="text-green-400" />
<StatCard
label="Thin Content (<300w)"
value={audit.thinContentCount}
accent={audit.thinContentCount > 0 ? 'text-amber-400' : 'text-green-400'}
good={audit.thinContentCount === 0}
/>
<StatCard
label="Missing Featured Image"
value={audit.missingFeaturedImageCount}
accent={audit.missingFeaturedImageCount > 0 ? 'text-amber-400' : 'text-green-400'}
good={audit.missingFeaturedImageCount === 0}
/>
<StatCard
label="Missing SEO Meta"
value={audit.missingSeoAnyCount}
accent={audit.missingSeoAnyCount > 0 ? 'text-amber-400' : 'text-green-400'}
good={audit.missingSeoAnyCount === 0}
/>
<StatCard label="Avg Word Count" value={audit.averageWordCount} accent="text-blue-400" />
</div>
</section>
)}
{/* Launch Checklist */}
<section>
<div className="flex items-center gap-3 mb-4">
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider">Launch Checklist</h2>
{launchReady && (
<span className="text-xs font-body font-semibold px-2 py-0.5 rounded-full bg-green-500/15 text-green-400">
LAUNCH READY
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ChecklistItem
label="Zero pages with missing featured images"
done={checklist?.zeroPagesWithMissingFeaturedImages ?? false}
/>
<ChecklistItem
label="Zero pages with thin content (under 300 words)"
done={checklist?.zeroPagesWithThinContent ?? false}
/>
<ChecklistItem
label="Zero pages missing SEO meta title and description"
done={checklist?.zeroPagesMissingSeoMeta ?? false}
/>
<ChecklistItem
label="Zero unrewritten press releases in published state"
done={checklist?.zeroUnrewrittenPressReleases ?? false}
/>
<ChecklistItem
label="Daily content pipeline running and generating new articles"
done={(status?.generatedToday ?? 0) > 0}
/>
<ChecklistItem
label="AI nodes online and responding"
done={!allNodesOffline}
/>
</div>
</section>
{/* Manual Controls */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">Manual Controls</h2>
<div className="max-w-lg">
<GeneratePanel onGenerated={fetchAll} />
</div>
</section>
{/* API Reference */}
<section>
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">API Endpoints</h2>
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ method: 'GET', path: '/api/content/audit', desc: 'Full content audit report' },
{ method: 'GET', path: '/api/content/pipeline-status', desc: 'Live pipeline + node status' },
{ method: 'POST', path: '/api/content/generate', desc: 'Generate one article { site, topic? }' },
{ method: 'GET', path: '/api/content/press-releases', desc: 'List unedited press releases' },
{ method: 'POST', path: '/api/content/press-releases', desc: 'Rewrite a press release { articleId, source, site }' },
{ method: 'POST', path: '/api/content/seo-batch', desc: 'Batch-generate missing SEO meta { site? }' },
{ method: 'GET', path: '/api/ai/node-health', desc: 'AI_001 node health check' },
{ method: 'GET', path: '/api/ai/ollama-health', desc: 'AI_001 ping check' },
].map((ep) => (
<div key={ep.path} className="flex items-start gap-2">
<span className={`text-xs font-body font-bold px-1.5 py-0.5 rounded flex-shrink-0 ${ep.method === 'GET' ? 'bg-blue-500/15 text-blue-400' : 'bg-green-500/15 text-green-400'}`}>
{ep.method}
</span>
<div>
<code className="text-xs text-[#ccc] font-mono">{ep.path}</code>
<p className="text-xs text-[#555] font-body mt-0.5">{ep.desc}</p>
</div>
</div>
))}
</div>
</section>
</div>
</div>
);
}