SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%
10.8 KB · 217 lines tsx
Raw Blame History
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