SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
7.7 KB · 145 lines tsx
Raw Blame History
1"use client";23import { Play } from "lucide-react";4import { useState } from "react";5import { Button } from "@/components/ui";6import type { ConnectorHealthDTO } from "@/lib/admin-api";7import { Checkbox, Field, Input, Select } from "./primitives";8import { useToast } from "./toast";9import { useAdminMutation } from "./use-admin-query";1011export type RunTask = "crawl" | "discover" | "full" | "reprocess";1213/** Inline "run connector" form: task, group, limit, force → POST connectors/:id/run. */14export function RunForm({ connector, onDone, compact }: { connector: Pick<ConnectorHealthDTO, "id" | "schedule">; onDone?: () => void; compact?: boolean }) {15  const [task, setTask] = useState<RunTask>("crawl");16  const [group, setGroup] = useState("");17  const [limit, setLimit] = useState("");18  const [force, setForce] = useState(false);19  const { busy, run } = useAdminMutation();20  const toast = useToast();21  const groups = Object.keys(connector.schedule ?? {});22  const submit = async () => {23    const body: Record<string, unknown> = { task };24    if (group) body.group = group;25    if (limit && Number(limit) > 0) body.limit = Math.floor(Number(limit));26    if (force) body.force = true;27    const r = await run<{ enqueued: boolean; job?: { id?: string; name?: string } }>("run", `connectors/${encodeURIComponent(connector.id)}/run`, { body });28    if (r.ok) {29      toast({ tone: "ok", title: `Run enqueued · ${connector.id}`, description: `task ${task}${group ? ` · group ${group}` : ""}${r.data.job?.id ? ` · job ${r.data.job.id}` : ""}` });30      onDone?.();31    } else toast({ tone: "danger", title: "Run failed to enqueue", description: r.error });32  };33  return (34    <div className={compact ? "grid grid-cols-2 gap-2 sm:grid-cols-[140px_1fr_110px_auto_auto] sm:items-end" : "grid gap-2 sm:grid-cols-[160px_1fr_120px_auto_auto] sm:items-end"}>35      <Field label="Task">36        <Select value={task} onChange={(e) => setTask(e.target.value as RunTask)}>37          <option value="crawl">crawl — due pages</option>38          <option value="discover">discover — sitemap / RSS / seeds</option>39          <option value="full">full — discover + crawl</option>40          <option value="reprocess">reprocess — re-extract stored bodies</option>41        </Select>42      </Field>43      <Field label="Group" hint={groups.length ? `schedule groups: ${groups.join(", ")}` : undefined}>44        <Input list={`groups-${connector.id}`} value={group} onChange={(e) => setGroup(e.target.value)} placeholder="all groups" mono />45        {groups.length > 0 && (46          <datalist id={`groups-${connector.id}`}>47            {groups.map((g) => (48              <option key={g} value={g} />49            ))}50          </datalist>51        )}52      </Field>53      <Field label="Limit">54        <Input type="number" min={1} max={100000} value={limit} onChange={(e) => setLimit(e.target.value)} placeholder="∞" mono />55      </Field>56      <Checkbox label="force" checked={force} onChange={(e) => setForce(e.target.checked)} />57      <Button variant="accent" size="md" onClick={() => void submit()} disabled={busy === "run"}>58        <Play className="size-3.5" strokeWidth={2} />59        Run60      </Button>61    </div>62  );63}6465/** Inline schedule editor: one interval per group → PATCH connectors/:id/schedule. */66export function ScheduleEditor({ connector, onSaved }: { connector: Pick<ConnectorHealthDTO, "id" | "schedule">; onSaved?: (schedule: Record<string, string>) => void }) {67  const [rows, setRows] = useState<Array<{ group: string; interval: string }>>(() => Object.entries(connector.schedule ?? {}).map(([group, interval]) => ({ group, interval })));68  const [error, setError] = useState<string | null>(null);69  const { busy, run } = useAdminMutation();70  const toast = useToast();71  const save = async () => {72    const body: Record<string, string> = {};73    for (const r of rows) {74      if (!r.group.trim()) continue;75      if (!/^[a-z0-9_*-]+$/i.test(r.group.trim())) return setError(`bad group name "${r.group}"`);76      if (!/^(hourly|daily|weekly|biweekly|monthly|quarterly|never|\d+\s*(m|min|h|d|w))$/i.test(r.interval.trim())) return setError(`bad interval for ${r.group}: "${r.interval}" (daily, weekly, 12h, 30m…)`);77      body[r.group.trim()] = r.interval.trim().toLowerCase();78    }79    if (!Object.keys(body).length) return setError("nothing to save");80    setError(null);81    const r = await run<{ id: string; schedule: Record<string, string>; file: { updated: boolean; error?: string; backup: string | null } }>("schedule", `connectors/${encodeURIComponent(connector.id)}/schedule`, { method: "PATCH", body });82    if (r.ok) {83      toast({ tone: r.data.file.updated ? "ok" : "warn", title: "Schedule saved", description: r.data.file.updated ? `YAML updated${r.data.file.backup ? " (backup kept)" : ""}` : `DB updated; YAML not updated: ${r.data.file.error ?? "unknown"}` });84      onSaved?.(r.data.schedule);85    } else {86      setError(r.error);87      toast({ tone: "danger", title: "Schedule not saved", description: r.error });88    }89  };90  return (91    <div className="space-y-2">92      <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">93        {rows.map((r, i) => (94          <div key={i} className="flex items-end gap-2">95            <Field label="Group" className="flex-1">96              <Input value={r.group} onChange={(e) => setRows((p) => p.map((x, j) => (j === i ? { ...x, group: e.target.value } : x)))} mono />97            </Field>98            <Field label="Interval" className="w-[120px]">99              <Input list="dci-intervals" value={r.interval} onChange={(e) => setRows((p) => p.map((x, j) => (j === i ? { ...x, interval: e.target.value } : x)))} mono />100            </Field>101            <Button size="md" variant="ghost" aria-label="Remove group" onClick={() => setRows((p) => p.filter((_, j) => j !== i))}>102              ×103            </Button>104          </div>105        ))}106      </div>107      <datalist id="dci-intervals">108        {["hourly", "3h", "6h", "12h", "daily", "weekly", "biweekly", "monthly", "quarterly", "never"].map((v) => (109          <option key={v} value={v} />110        ))}111      </datalist>112      {error && <p className="text-[12px] text-danger">{error}</p>}113      <div className="flex flex-wrap gap-2">114        <Button size="sm" variant="ghost" onClick={() => setRows((p) => [...p, { group: "", interval: "weekly" }])}>115          + group116        </Button>117        <Button size="sm" variant="primary" onClick={() => void save()} disabled={busy === "schedule"}>118          Save schedule119        </Button>120        <span className="self-center text-[11.5px] text-ink-4">Intervals: hourly, daily, weekly, biweekly, monthly, quarterly, never, or 30m / 12h / 3d / 2w. Writes the DB row and the YAML file (backup kept).</span>121      </div>122    </div>123  );124}125126/** Pause / resume toggle → POST connectors/:id/pause|resume. */127export function PauseResumeButton({ connector, onChanged, size = "xs" }: { connector: Pick<ConnectorHealthDTO, "id" | "health">; onChanged?: (paused: boolean) => void; size?: "xs" | "sm" }) {128  const { busy, run } = useAdminMutation();129  const toast = useToast();130  const paused = connector.health === "paused";131  const toggle = async () => {132    const action = paused ? "resume" : "pause";133    const r = await run<{ id: string; paused: boolean; health: string }>(action, `connectors/${encodeURIComponent(connector.id)}/${action}`, { body: {} });134    if (r.ok) {135      toast({ tone: "ok", title: `${connector.id} ${r.data.paused ? "paused" : "resumed"}` });136      onChanged?.(r.data.paused);137    } else toast({ tone: "danger", title: `Could not ${action}`, description: r.error });138  };139  return (140    <Button size={size} variant={paused ? "secondary" : "ghost"} onClick={() => void toggle()} disabled={busy !== null}>141      {paused ? "Resume" : "Pause"}142    </Button>143  );144}145