spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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