SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
5.6 KB · 139 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { useRouter } from "next/navigation";4import { ArrowDown, ArrowUp, Eraser, Plus, Save, X } from "lucide-react";5import { resetDomainStats, updateDomainPolicy, type AdminActionResult } from "@/actions/admin";6import { Button } from "@/components/ui/button";7import { NativeSelect } from "@/components/ui/input";8import { Field, Hint, Label } from "@/components/ui/label";9import { ActionButton, ResultMessage } from "./action-button";1011const NETWORKS = ["datacenter", "residential", "isp", "mobile"] as const;1213export function DomainPolicyEditor({ domain, policy, availableKeys }: { domain: string; policy: { order?: string[]; force_network?: string } | null; availableKeys: string[] }) {14  const router = useRouter();15  const [pending, start] = React.useTransition();16  const [force, setForce] = React.useState(policy?.force_network ?? "");17  const [order, setOrder] = React.useState<string[]>(policy?.order ?? []);18  const [pick, setPick] = React.useState("");19  const [result, setResult] = React.useState<AdminActionResult | null>(null);20  const candidates = availableKeys.filter((k) => !order.includes(k));2122  const move = (i: number, d: -1 | 1) =>23    setOrder((cur) => {24      const next = [...cur];25      const j = i + d;26      if (j < 0 || j >= next.length) return cur;27      [next[i], next[j]] = [next[j]!, next[i]!];28      return next;29    });3031  return (32    <form33      className="grid gap-4"34      onSubmit={(e) => {35        e.preventDefault();36        start(async () => {37          const r = await updateDomainPolicy(domain, { force_network: force || null, order });38          setResult(r);39          if (r.ok) router.refresh();40        });41      }}42    >43      <Field>44        <Label htmlFor="force-network">Force network</Label>45        <NativeSelect id="force-network" value={force} onChange={(e) => setForce(e.target.value)} className="w-[220px]">46          <option value="">None (auto escalation)</option>47          {NETWORKS.map((n) => (48            <option key={n} value={n}>49              {n}50            </option>51          ))}52        </NativeSelect>53        <Hint>Applies only when the customer asks for `network: &quot;auto&quot;`. Explicit network requests are never overridden.</Hint>54      </Field>5556      <div className="grid gap-1.5">57        <Label>Pinned route order</Label>58        <Hint>Ordered list of `provider:network` keys. Pinned routes are tried first (in this order); unpinned candidates follow by score.</Hint>59        {order.length ? (60          <ol className="divide-y divide-border rounded-md border border-border">61            {order.map((k, i) => (62              <li key={k} className="flex items-center gap-2 px-2.5 py-1.5 text-[13px]">63                <span className="w-5 text-right tabular font-mono text-fg-subtle">{i + 1}.</span>64                <span className="font-mono">{k}</span>65                <span className="ml-auto flex items-center gap-0.5">66                  <Button type="button" variant="ghost" size="icon-sm" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Move up">67                    <ArrowUp className="size-3.5" />68                  </Button>69                  <Button type="button" variant="ghost" size="icon-sm" onClick={() => move(i, 1)} disabled={i === order.length - 1} aria-label="Move down">70                    <ArrowDown className="size-3.5" />71                  </Button>72                  <Button type="button" variant="ghost" size="icon-sm" onClick={() => setOrder((cur) => cur.filter((x) => x !== k))} aria-label={`Remove ${k}`}>73                    <X className="size-3.5" />74                  </Button>75                </span>76              </li>77            ))}78          </ol>79        ) : (80          <p className="rounded-md border border-dashed border-border px-3 py-2 text-[12.5px] text-fg-muted">No pinned routes — the engine orders candidates by score.</p>81        )}82        <div className="flex items-center gap-2">83          <NativeSelect value={pick} onChange={(e) => setPick(e.target.value)} className="w-[260px]" aria-label="Route to pin">84            <option value="">Add a route…</option>85            {candidates.map((k) => (86              <option key={k} value={k}>87                {k}88              </option>89            ))}90          </NativeSelect>91          <Button92            type="button"93            variant="outline"94            size="sm"95            disabled={!pick}96            onClick={() => {97              if (pick) setOrder((cur) => [...cur, pick]);98              setPick("");99            }}100          >101            <Plus className="size-3.5" /> Pin102          </Button>103        </div>104      </div>105106      <div className="flex flex-wrap items-center gap-3">107        <Button type="submit" size="sm" loading={pending}>108          <Save className="size-3.5" /> Save policy109        </Button>110        <Button111          type="button"112          variant="ghost"113          size="sm"114          onClick={() => {115            setForce("");116            setOrder([]);117          }}118        >119          Clear120        </Button>121        <ResultMessage result={result} />122      </div>123    </form>124  );125}126127export function ResetDomainStatsButton({ domain }: { domain: string }) {128  return (129    <ActionButton130      variant="outline"131      size="sm"132      action={() => resetDomainStats(domain)}133      confirm={{ title: `Reset learned stats for ${domain}?`, description: "Route statistics, block/captcha counters and the preferred route are cleared. The policy is kept. The router falls back to network priors until new data accumulates.", confirmLabel: "Reset stats" }}134    >135      <Eraser className="size-3.5" /> Reset stats136    </ActionButton>137  );138}139