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%
7.5 KB · 175 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { useRouter } from "next/navigation";4import { RefreshCw, RotateCcw, Save } from "lucide-react";5import { resetCircuit, runProviderProbe, updateProviderConfig, type AdminActionResult, type ProviderConfigInput } from "@/actions/admin";6import { Button } from "@/components/ui/button";7import { Input, Textarea } from "@/components/ui/input";8import { Field, Hint, Label } from "@/components/ui/label";9import { Switch } from "@/components/ui/switch";10import { Badge, StatusBadge } from "@/components/ui/badge";11import { ActionButton, ResultMessage } from "./action-button";1213const ALL_NETWORKS = ["datacenter", "residential", "isp", "mobile"] as const;1415export interface ProviderConfigFormProps {16  id: string;17  label: string;18  enabled: boolean;19  networks: string[];20  pricePerGbUsd: Record<string, number>;21  weight: number;22  maxConcurrency: number;23  notes: string | null;24  /** Prices reported live by the API (fallback when no config row exists). */25  livePrices: Record<string, number>;26  liveNetworks: string[];27  hasConfigRow: boolean;28}2930export function ProviderConfigForm(p: ProviderConfigFormProps) {31  const router = useRouter();32  const [pending, start] = React.useTransition();33  const [result, setResult] = React.useState<AdminActionResult | null>(null);34  const [enabled, setEnabled] = React.useState(p.enabled);35  const [networks, setNetworks] = React.useState<string[]>(p.networks.length ? p.networks : p.liveNetworks);36  const [prices, setPrices] = React.useState<Record<string, string>>(() => {37    const out: Record<string, string> = {};38    for (const n of ALL_NETWORKS) {39      const v = p.pricePerGbUsd[n] ?? p.livePrices[n];40      if (v !== undefined) out[n] = String(v);41    }42    return out;43  });44  const [weight, setWeight] = React.useState(String(p.weight));45  const [maxConcurrency, setMaxConcurrency] = React.useState(String(p.maxConcurrency));46  const [notes, setNotes] = React.useState(p.notes ?? "");4748  const submit = (e: React.FormEvent) => {49    e.preventDefault();50    start(async () => {51      const pricePerGbUsd: Record<string, number> = {};52      for (const [k, v] of Object.entries(prices)) if (v.trim() !== "" && networks.includes(k)) pricePerGbUsd[k] = Number(v);53      const input: ProviderConfigInput = {54        label: p.label,55        enabled,56        networks: networks as ProviderConfigInput["networks"],57        pricePerGbUsd,58        weight: Number(weight),59        maxConcurrency: Number(maxConcurrency),60        notes: notes.trim() || null,61      };62      const r = await updateProviderConfig(p.id, input);63      setResult(r);64      if (r.ok) router.refresh();65    });66  };6768  return (69    <form onSubmit={submit} className="grid gap-4">70      <div className="flex items-center gap-3">71        <Switch id={`en-${p.id}`} checked={enabled} onCheckedChange={setEnabled} aria-label="Enabled" />72        <Label htmlFor={`en-${p.id}`}>Enabled in routing</Label>73        {!p.hasConfigRow ? <Badge variant="outline">no config row yet — defaults shown</Badge> : null}74      </div>75      <div>76        <div className="mb-1.5 text-[13px] font-medium">Networks and price per GB (USD)</div>77        <div className="grid gap-2 sm:grid-cols-2">78          {ALL_NETWORKS.map((n) => {79            const on = networks.includes(n);80            return (81              <div key={n} className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">82                <input83                  type="checkbox"84                  id={`${p.id}-${n}`}85                  className="size-3.5 accent-[var(--accent)]"86                  checked={on}87                  onChange={(e) => setNetworks((cur) => (e.target.checked ? [...cur, n] : cur.filter((x) => x !== n)))}88                />89                <label htmlFor={`${p.id}-${n}`} className="w-24 text-[13px]">90                  {n}91                </label>92                <span className="text-[12px] text-fg-subtle">$</span>93                <Input94                  aria-label={`${n} price per GB`}95                  type="number"96                  min={0}97                  step="0.01"98                  inputMode="decimal"99                  disabled={!on}100                  value={prices[n] ?? ""}101                  onChange={(e) => setPrices((cur) => ({ ...cur, [n]: e.target.value }))}102                  className="h-7 w-24 font-mono text-[12.5px]"103                  placeholder="—"104                />105                <span className="text-[12px] text-fg-subtle">/GB</span>106              </div>107            );108          })}109        </div>110      </div>111      <div className="grid gap-3 sm:grid-cols-2">112        <Field>113          <Label htmlFor={`w-${p.id}`}>Weight</Label>114          <Input id={`w-${p.id}`} type="number" min={0} max={10} step="0.1" value={weight} onChange={(e) => setWeight(e.target.value)} className="font-mono" />115          <Hint>Relative preference multiplier (1 = neutral).</Hint>116        </Field>117        <Field>118          <Label htmlFor={`mc-${p.id}`}>Max concurrency</Label>119          <Input id={`mc-${p.id}`} type="number" min={1} step={1} value={maxConcurrency} onChange={(e) => setMaxConcurrency(e.target.value)} className="font-mono" />120          <Hint>Upstream connection ceiling for this provider.</Hint>121        </Field>122      </div>123      <Field>124        <Label htmlFor={`notes-${p.id}`}>Notes</Label>125        <Textarea id={`notes-${p.id}`} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Contract terms, account manager, quotas…" className="min-h-[64px]" />126      </Field>127      <div className="flex items-center gap-3">128        <Button type="submit" size="sm" loading={pending}>129          <Save className="size-3.5" /> Save &amp; reload providers130        </Button>131        <ResultMessage result={result} />132      </div>133    </form>134  );135}136137export function CircuitList({ circuits }: { circuits: Array<{ key: string; state: string; failures: number; successes: number; openedAt: number | null }> }) {138  if (!circuits.length) return <p className="text-[12.5px] text-fg-muted">No circuit entries yet — circuits are created on the first attempt per route.</p>;139  return (140    <ul className="divide-y divide-border">141      {circuits.map((c) => (142        <li key={c.key} className="flex flex-wrap items-center gap-3 py-2 text-[12.5px]">143          <span className="font-mono">{c.key}</span>144          <StatusBadge status={c.state === "closed" ? "healthy" : c.state === "open" ? "down" : c.state} />145          <span className="tabular font-mono text-fg-muted">146            {c.successes} ok / {c.failures} fail147          </span>148          {c.openedAt ? <span className="text-fg-subtle">opened {new Date(c.openedAt).toLocaleTimeString()}</span> : null}149          <span className="ml-auto">150            <ActionButton variant="outline" size="xs" action={() => resetCircuit(c.key)}>151              <RotateCcw className="size-3" /> Reset circuit152            </ActionButton>153          </span>154        </li>155      ))}156    </ul>157  );158}159160export function ProbeButton() {161  return (162    <ActionButton variant="primary" size="sm" action={() => runProviderProbe()}>163      <RefreshCw className="size-3.5" /> Run health probe now164    </ActionButton>165  );166}167168export function ResetAllCircuitsButton() {169  return (170    <ActionButton variant="outline" size="sm" action={() => resetCircuit()} confirm={{ title: "Reset every circuit breaker?", description: "All routes become eligible again immediately, including ones that were failing.", confirmLabel: "Reset all" }}>171      <RotateCcw className="size-3.5" /> Reset all circuits172    </ActionButton>173  );174}175