mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
## Summary
`fabro model test` (CLI) probes every configured model with a cheap "Say
OK" prompt and prints a results table. Until now, the equivalent on
`/settings/models` was "open a terminal." This PR adds a single **Test
models** button in the section header that runs the same sweep against
the visible rows and renders per-row results inline. Wire format is the
existing `POST /api/v1/models/{id}/test` — no backend changes.
## Behavior
- One button beside the provider filter + search. Tests *whatever the
table currently shows* (filter + search applied at click time).
- Concurrency cap of 4 to mirror the CLI's `--jobs 4` default.
- Rows render `Queued` → `Testing…` → `Ok` (mint check) or red X +
truncated error (full message on hover via `title`).
- After each sweep, a small `N ok · M failed` chip appears next to the
button (mint when clean, coral on failures).
- Re-clicking starts a fresh sweep over the current view.
## Out of scope (deliberately)
- **No deep-test toggle** — page calls basic mode only; `fabro model
test --deep` still covers that case from the CLI.
- **No per-row Test button** — the page-level sweep replaces it.
- No cancellation, no result persistence across navigation/refresh, no
toast — the inline state *is* the feedback.
## Files
- `apps/fabro-web/app/routes/settings-models.tsx` — `RowState`/`Sweep`
types, `runSweep` worker pool, header button + summary chip, new "Test"
column, `TestStatusCell` component.
- `apps/fabro-web/app/components/state.tsx` — `Spinner` is now exported
(was previously private).
## Test plan
- Click "Test models" with several configured providers → rows flip in
waves of 4; summary lands as `N ok · 0 failed`.
- Revoke a provider's API key, click again → that provider's rows end in
red X with the upstream error in the cell (full text on hover).
- Apply a provider filter, click → only filtered rows test.
- DevTools Network panel → at most 4 in-flight `/models/<id>/test`
requests at any time.
---
[](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via
[Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
2.6 KiB
TypeScript
102 lines
2.6 KiB
TypeScript
import type { ComponentType, ReactNode, SVGProps } from "react";
|
|
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
|
|
|
import { SECONDARY_BUTTON_CLASS } from "./ui";
|
|
|
|
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
|
|
|
|
// Shared chrome for non-critical "the content isn't there" states. Full-page
|
|
// crashes are handled by the root ErrorBoundary; these render inside the
|
|
// current content column.
|
|
function StatePanel({ children }: { children: ReactNode }) {
|
|
return (
|
|
<div className="mx-auto flex max-w-md flex-col items-center rounded-md border border-line bg-panel p-8 text-center">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({
|
|
icon: Icon,
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
icon?: IconComponent;
|
|
title: string;
|
|
description?: string;
|
|
action?: ReactNode;
|
|
}) {
|
|
return (
|
|
<StatePanel>
|
|
{Icon ? (
|
|
<Icon className="mb-3 size-6 text-fg-muted" aria-hidden="true" />
|
|
) : null}
|
|
<p className="text-sm font-medium text-fg">{title}</p>
|
|
{description ? (
|
|
<p className="mt-1 max-w-[48ch] text-sm/6 text-fg-3">{description}</p>
|
|
) : null}
|
|
{action ? <div className="mt-4">{action}</div> : null}
|
|
</StatePanel>
|
|
);
|
|
}
|
|
|
|
export function ErrorState({
|
|
title = "Something went wrong",
|
|
description,
|
|
onRetry,
|
|
}: {
|
|
title?: string;
|
|
description?: string;
|
|
onRetry?: () => void;
|
|
}) {
|
|
return (
|
|
<StatePanel>
|
|
<ExclamationTriangleIcon
|
|
className="mb-3 size-6 text-coral"
|
|
aria-hidden="true"
|
|
/>
|
|
<p className="text-sm font-medium text-fg">{title}</p>
|
|
{description ? (
|
|
<p className="mt-1 max-w-[48ch] text-sm/6 text-fg-3">{description}</p>
|
|
) : null}
|
|
{onRetry ? (
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
className={`${SECONDARY_BUTTON_CLASS} mt-4`}
|
|
>
|
|
Try again
|
|
</button>
|
|
) : null}
|
|
</StatePanel>
|
|
);
|
|
}
|
|
|
|
export function LoadingState({ label }: { label?: string }) {
|
|
return (
|
|
<StatePanel>
|
|
<Spinner className="mb-3 size-5 text-teal-500" />
|
|
<p className="text-sm text-fg-3">{label ?? "Loading…"}</p>
|
|
</StatePanel>
|
|
);
|
|
}
|
|
|
|
export function Spinner({ className = "" }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
className={`shrink-0 animate-spin ${className}`}
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
|
|
<path
|
|
d="M14 8a6 6 0 0 0-6-6"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|