spb/social-runtime-crawler
Public
TypeScript 91.8%
HTML 3.2%
JavaScript 3%
SQL 1.4%
CSS 0.7%
1"use client";23import { useEffect, useState } from "react";4import Link from "next/link";5import { Play, Square } from "lucide-react";6import { api, useApi } from "@/lib/api";7import { ago, truncate } from "@/lib/format";8import { Empty, Panel, Skeleton, StatusPill, Table, Tag } from "@/components/ui";910interface Job {11 job_id: string;12 session_id: string | null;13 platform: string;14 mode: string;15 goal: string;16 budget: { minutes: number; actions: number; media: number; query?: string | null; headless: boolean; account: string };17 status: string;18 created_at: string;19 finished_at: string | null;20 result: { steps?: number; entities?: number; videos?: number; ended_because?: string } | null;21 actions: number;22 entities: number;23}2425const PRESETS = [26 { label: "Topic · AI Québec on YouTube", platform: "youtube", mode: "topic", query: "intelligence artificielle Québec", goal: "Discover public Quebec creators and channels discussing artificial intelligence", minutes: 10, actions: 60, media: 2 },27 { label: "Learn · YouTube runtime", platform: "youtube", mode: "learn", query: "", goal: "", minutes: 15, actions: 80, media: 1 },28 { label: "Observe · YouTube home feed", platform: "youtube", mode: "observe", query: "", goal: "Observe the recommendation feed without navigating", minutes: 5, actions: 30, media: 0 },29 { label: "Research · Reddit r/Quebec AI", platform: "reddit", mode: "research", query: "intelligence artificielle Québec", goal: "Discover public Reddit posts and users discussing AI in Quebec", minutes: 10, actions: 60, media: 1 },30 { label: "Facebook · public page prototype (§52)", platform: "facebook", mode: "research", query: "", goal: "Observe one public Facebook page: its posts, videos, comments and the public pages/profiles they link to", minutes: 8, actions: 40, media: 1 },31 { label: "Observe · Facebook feed", platform: "facebook", mode: "observe", query: "", goal: "Observe the Facebook home feed and its recommendation exposure without navigating", minutes: 5, actions: 30, media: 0 },32];3334export default function Missions() {35 const jobs = useApi<Job[]>("/jobs", { refreshMs: 5000 });36 const [form, setForm] = useState({ platform: "youtube", mode: "research", query: "intelligence artificielle Québec", goal: "Discover public Quebec creators discussing AI", minutes: 10, actions: 60, media: 1, headless: true, account: "research-01" });37 const [busy, setBusy] = useState(false);38 const [msg, setMsg] = useState<string | null>(null);39 const [logJob, setLogJob] = useState<string | null>(null);40 const input = "w-full rounded-lg bg-bg-2 border border-line-2 px-3 py-2 text-sm outline-none focus:border-cyan/60";4142 async function launch() {43 setBusy(true);44 setMsg(null);45 try {46 const r = await api<{ job_id: string }>("/jobs", { method: "POST", body: JSON.stringify(form) });47 setMsg(`mission ${r.job_id} launched`);48 setLogJob(r.job_id);49 jobs.refresh();50 } catch (e) {51 setMsg((e as Error).message);52 } finally {53 setBusy(false);54 }55 }56 async function stop(id: string) {57 await api(`/jobs/${id}/stop`, { method: "POST", body: "{}" });58 jobs.refresh();59 }6061 return (62 <div className="space-y-4">63 <div>64 <h1 className="text-2xl font-semibold tracking-tight">Missions</h1>65 <p className="text-sm text-fg-2 max-w-2xl">A mission opens one browser on the cluster node, gives the agent a goal and a budget, and lets it explore. Read-only: the crawler never likes, follows, comments or subscribes.</p>66 </div>67 <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">68 <Panel title="New mission" className="xl:col-span-1">69 <div className="space-y-3">70 <div className="flex flex-wrap gap-1">71 {PRESETS.map((p) => (72 <button key={p.label} onClick={() => setForm((f) => ({ ...f, ...p }))} className="text-[11px] rounded-md border border-line px-2 py-1 hover:border-cyan/50 text-fg-2 hover:text-fg">73 {p.label}74 </button>75 ))}76 </div>77 <div className="grid grid-cols-2 gap-2">78 <label className="text-xs text-dim">79 platform80 <select value={form.platform} onChange={(e) => setForm({ ...form, platform: e.target.value })} className={input}>81 <option value="youtube">youtube</option>82 <option value="reddit">reddit</option>83 <option value="facebook">facebook</option>84 </select>85 </label>86 <label className="text-xs text-dim">87 mode88 <select value={form.mode} onChange={(e) => setForm({ ...form, mode: e.target.value })} className={input}>89 {["research", "topic", "observe", "profile", "learn"].map((m) => (90 <option key={m}>{m}</option>91 ))}92 </select>93 </label>94 </div>95 <label className="text-xs text-dim block">96 topic / search query97 <input value={form.query} onChange={(e) => setForm({ ...form, query: e.target.value })} className={input} placeholder="e.g. intelligence artificielle Québec" />98 </label>99 <label className="text-xs text-dim block">100 goal (what the agent optimises for)101 <textarea value={form.goal} onChange={(e) => setForm({ ...form, goal: e.target.value })} rows={2} className={input} />102 </label>103 <div className="grid grid-cols-3 gap-2">104 <label className="text-xs text-dim">105 minutes106 <input type="number" min={1} max={120} value={form.minutes} onChange={(e) => setForm({ ...form, minutes: Number(e.target.value) })} className={`${input} mono`} />107 </label>108 <label className="text-xs text-dim">109 actions110 <input type="number" min={1} max={1000} value={form.actions} onChange={(e) => setForm({ ...form, actions: Number(e.target.value) })} className={`${input} mono`} />111 </label>112 <label className="text-xs text-dim">113 media level114 <select value={form.media} onChange={(e) => setForm({ ...form, media: Number(e.target.value) })} className={input}>115 <option value={0}>0 · metadata</option>116 <option value={1}>1 · + thumbnail</option>117 <option value={2}>2 · + frames</option>118 </select>119 </label>120 </div>121 <div className="grid grid-cols-2 gap-2">122 <label className="text-xs text-dim">123 account profile124 <input value={form.account} onChange={(e) => setForm({ ...form, account: e.target.value })} className={`${input} mono`} />125 </label>126 <label className="text-xs text-dim flex items-end gap-2 pb-2">127 <input type="checkbox" checked={form.headless} onChange={(e) => setForm({ ...form, headless: e.target.checked })} /> headless browser128 </label>129 </div>130 <button disabled={busy} onClick={launch} className="w-full rounded-lg bg-cyan/90 hover:bg-cyan disabled:opacity-50 text-bg font-semibold py-2 flex items-center justify-center gap-2">131 <Play className="h-4 w-4" /> Launch mission132 </button>133 {msg && <div className="text-xs text-fg-2 mono">{msg}</div>}134 </div>135 </Panel>136 <Panel title="Missions" className="xl:col-span-2" pad={false} right={jobs.data ? `${jobs.data.filter((j) => j.status === "running").length} running` : ""}>137 <div className="p-4">138 {jobs.loading ? (139 <Skeleton rows={6} />140 ) : jobs.data?.length ? (141 <Table head={["status", "platform", "goal", "mode", "budget", "steps", "entities", "created", ""]} dense>142 {jobs.data.map((j) => (143 <tr key={j.job_id}>144 <td>145 <StatusPill status={j.status} />146 </td>147 <td>148 <Tag>{j.platform}</Tag>149 </td>150 <td className="max-w-[360px]">151 {j.session_id ? (152 <Link href={`/sessions/${j.session_id}`} className="hover:text-cyan">153 {truncate(j.goal, 80)}154 </Link>155 ) : (156 truncate(j.goal, 80)157 )}158 {j.budget?.query && <div className="text-[10.5px] text-dim">“{j.budget.query}”</div>}159 </td>160 <td className="text-fg-2">{j.mode}</td>161 <td className="mono text-[11px] text-dim">162 {j.budget?.minutes}m · {j.budget?.actions}a · L{j.budget?.media}163 </td>164 <td className="mono">{j.actions || j.result?.steps || 0}</td>165 <td className="mono">{j.entities || j.result?.entities || 0}</td>166 <td className="text-dim whitespace-nowrap">{ago(j.created_at)}</td>167 <td className="whitespace-nowrap">168 <button onClick={() => setLogJob(j.job_id)} className="text-[11px] text-dim hover:text-cyan mr-2">169 log170 </button>171 {j.status === "running" && (172 <button onClick={() => stop(j.job_id)} className="text-[11px] text-red hover:text-red/80 inline-flex items-center gap-1">173 <Square className="h-3 w-3" /> stop174 </button>175 )}176 </td>177 </tr>178 ))}179 </Table>180 ) : (181 <Empty>No missions yet — launch one on the left.</Empty>182 )}183 </div>184 </Panel>185 </div>186 {logJob && <JobLog id={logJob} onClose={() => setLogJob(null)} />}187 </div>188 );189}190191function JobLog({ id, onClose }: { id: string; onClose: () => void }) {192 const [text, setText] = useState("");193 useEffect(() => {194 let alive = true;195 let timer: ReturnType<typeof setTimeout> | undefined;196 const tick = async () => {197 try {198 const r = await fetch(`/api/jobs/${id}/log?lines=300`, { cache: "no-store" });199 if (alive) setText(await r.text());200 } catch {201 /* ignore */202 }203 if (alive) timer = setTimeout(tick, 3000);204 };205 tick();206 return () => {207 alive = false;208 if (timer) clearTimeout(timer);209 };210 }, [id]);211 return (212 <Panel title={`Worker log · ${id}`} right={<button onClick={onClose} className="hover:text-cyan">close</button>}>213 <pre className="mono text-[11px] leading-5 text-fg-2 whitespace-pre-wrap max-h-[420px] overflow-auto scrollbar-thin">{text || "…"}</pre>214 </Panel>215 );216}217