feat(connections): Test button — opens the provider round-trip and reports back

Backend: POST /api/connections/{id}/test decrypts the stored config, calls
provider.OpenAsync with a 10s CancellationTokenSource, closes the returned
handle in a finally, and returns {ok: bool, error?: string}. Always 200
unless the id is unknown (404) — the outcome of the round-trip belongs in
the body, not the status. 2 new xUnit tests cover the 404 path and the
"connection refused" failure path (points at 127.0.0.1:1).

Frontend: Plug icon on each ConnectionsPage row. Click → animated icon
during the round-trip, then an inline pill: 'Connected' (green check) on
success or the truncated error (red X, full text on hover) on failure.
Auto-clears after 6s. Row state is tracked per-id in a plain useState;
the shared react-query cache stays untouched. 1 new Vitest covers both
outcomes via MSW handlers.
This commit is contained in:
Claude
2026-08-29 07:41:19 +00:00
parent 1a1f519144
commit a06e9e0e84
4 changed files with 169 additions and 22 deletions

View File

@@ -68,6 +68,28 @@ describe("ConnectionsPage", () => {
});
});
it("Test connection button reports ok or error inline", async () => {
mswServer.use(
http.get("/api/connection-providers", () => HttpResponse.json(providers)),
http.get("/api/connections", () =>
HttpResponse.json([
{ id: "ok1", providerId: "nats", name: "reachable", config: { url: "nats://good" } },
{ id: "bad1", providerId: "nats", name: "unreachable", config: { url: "nats://bad" } },
] as never)),
http.post("/api/connections/ok1/test", () => HttpResponse.json({ ok: true } as never)),
http.post("/api/connections/bad1/test", () => HttpResponse.json({ ok: false, error: "connection refused" } as never)),
);
renderWithProviders(<ConnectionsPage />);
await screen.findByText("reachable");
const testButtons = screen.getAllByRole("button", { name: /test connection/i });
// First row = "reachable"
await userEvent.click(testButtons[0]);
await waitFor(() => expect(screen.getByText(/^Connected$/)).toBeInTheDocument());
// Second row = "unreachable"
await userEvent.click(testButtons[1]);
await waitFor(() => expect(screen.getByText(/connection refused/i)).toBeInTheDocument());
});
it("creates a new connection using the provider's schema", async () => {
let created: unknown = null;
mswServer.use(

View File

@@ -1,10 +1,12 @@
import { useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Check, Pencil, Plug, Plus, Trash2, X } from "lucide-react";
import { apiFetch } from "@/api/client";
import { qk, useApiMutation, useConnections, useInvalidate, useProviders } from "@/api/hooks";
import { SchemaForm } from "@/forms/SchemaForm";
import type { ConnectionDto, JsonSchemaObject } from "@/api/types";
type TestResult = { ok: true } | { ok: false; error: string };
type ProviderDto = { id: string; displayName: string; configSchema: JsonSchemaObject };
export default function ConnectionsPage() {
@@ -30,6 +32,29 @@ export default function ConnectionsPage() {
{ onSuccess: () => invalidate(qk.connections) },
);
// Per-row test status: id → { pending?: true, result?: TestResult }.
// Auto-clears after 6s. Stored outside react-query because each row has its
// own transient state that doesn't belong in the shared cache.
const [testState, setTestState] = useState<Record<string, { pending?: boolean; result?: TestResult }>>({});
const testTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
useEffect(() => () => { for (const t of Object.values(testTimers.current)) clearTimeout(t); }, []);
async function runTest(id: string) {
setTestState((s) => ({ ...s, [id]: { pending: true } }));
let result: TestResult;
try {
result = await apiFetch<TestResult>(`/api/connections/${id}/test`, { method: "POST" });
} catch (e) {
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
}
setTestState((s) => ({ ...s, [id]: { result } }));
if (testTimers.current[id]) clearTimeout(testTimers.current[id]);
testTimers.current[id] = setTimeout(
() => setTestState((s) => { const n = { ...s }; delete n[id]; return n; }),
6000,
);
}
const editing = openForm && openForm !== "new"
? conns.data?.find((c) => c.id === openForm) ?? null
: null;
@@ -75,26 +100,45 @@ export default function ConnectionsPage() {
)}
<ul className="divide-y divide-slate-800 border border-slate-800 rounded overflow-hidden">
{conns.data?.map((c) => (
<li key={c.id} className="flex items-center gap-3 p-3 hover:bg-slate-900">
<span className="flex-1">{c.name}</span>
<span className="text-xs text-slate-400">{c.providerId}</span>
<button
onClick={() => setOpenForm(c.id)}
className="text-slate-400 hover:text-slate-200"
aria-label="Edit"
>
<Pencil size={16} />
</button>
<button
onClick={() => confirm(`Delete "${c.name}"?`) && remove.mutate(c.id)}
className="text-slate-400 hover:text-red-400"
aria-label="Delete"
>
<Trash2 size={16} />
</button>
</li>
))}
{conns.data?.map((c) => {
const ts = testState[c.id];
return (
<li key={c.id} className="flex items-center gap-3 p-3 hover:bg-slate-900">
<span className="flex-1">{c.name}</span>
<span className="text-xs text-slate-400">{c.providerId}</span>
{ts?.result && (
ts.result.ok
? <span className="text-xs text-emerald-400 flex items-center gap-1"><Check size={12} /> Connected</span>
: <span className="text-xs text-red-400 flex items-center gap-1" title={ts.result.error}>
<X size={12} /> {truncate(ts.result.error, 30)}
</span>
)}
<button
onClick={() => runTest(c.id)}
disabled={ts?.pending}
className="text-slate-400 hover:text-emerald-300 disabled:opacity-40"
aria-label="Test connection"
title="Test connection"
>
<Plug size={16} className={ts?.pending ? "animate-pulse" : ""} />
</button>
<button
onClick={() => setOpenForm(c.id)}
className="text-slate-400 hover:text-slate-200"
aria-label="Edit"
>
<Pencil size={16} />
</button>
<button
onClick={() => confirm(`Delete "${c.name}"?`) && remove.mutate(c.id)}
className="text-slate-400 hover:text-red-400"
aria-label="Delete"
>
<Trash2 size={16} />
</button>
</li>
);
})}
{conns.data && conns.data.length === 0 && (
<li className="p-6 text-slate-500 text-sm">No connections yet.</li>
)}
@@ -103,6 +147,10 @@ export default function ConnectionsPage() {
);
}
function truncate(s: string, max: number) {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
function ConnectionForm({
mode, providers, initialProviderId, initialName, initialConfig,
submitting, onSubmit, onCancel,

View File

@@ -2,6 +2,8 @@ using System.Text.Json;
using VolcanicCore.Api.Auth;
using VolcanicCore.Api.Contracts;
using VolcanicCore.Api.Engine;
using VolcanicCore.Api.Registries;
using VolcanicCore.Core;
using VolcanicCore.Persistence.Repositories;
namespace VolcanicCore.Api.Endpoints;
@@ -82,6 +84,50 @@ public static class ConnectionsEndpoints
await instances.InvalidateAsync(id);
return repo.Delete(id) ? Results.NoContent() : Results.NotFound();
});
// Open the connection through its provider with a fixed timeout, close
// the handle immediately, and return whether that round-trip worked.
// Uses the stored config (secrets + all) so this validates the row as
// it will run inside the workflow — not a mid-edit form snapshot.
g.MapPost("/{id}/test", async (string id, ConnectionRepository repo,
ConnectionProviderRegistry providers, CancellationToken ct) =>
{
var row = repo.Get(id);
if (row is null) return Results.NotFound();
IConnectionProvider provider;
try { provider = providers.Get(row.ProviderId); }
catch (KeyNotFoundException)
{
return Results.Ok(new { ok = false, error = $"Unknown provider: {row.ProviderId}" });
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(10));
using var doc = JsonDocument.Parse(row.ConfigJson);
object? handle = null;
try
{
handle = await provider.OpenAsync(doc.RootElement, cts.Token);
return Results.Ok(new { ok = true });
}
catch (OperationCanceledException)
{
return Results.Ok(new { ok = false, error = "Timed out after 10s." });
}
catch (Exception ex)
{
return Results.Ok(new { ok = false, error = ex.Message });
}
finally
{
if (handle is not null)
{
try { await provider.CloseAsync(handle); } catch { /* best-effort */ }
}
}
});
}
// For each field in `secrets`, if the incoming config lacks that key OR

View File

@@ -229,5 +229,36 @@ public class ConnectionsEndpointsTests : IClassFixture<WebFactory>
Assert.Equal(HttpStatusCode.BadRequest, put.StatusCode);
}
[Fact]
public async Task Test_unknown_id_returns_404()
{
var c = await LoggedIn();
var res = await c.PostAsync("/api/connections/nope/test", content: null);
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}
[Fact]
public async Task Test_failing_nats_connect_returns_200_ok_false_with_message()
{
// 127.0.0.1:1 has no listener on any healthy CI/dev host — NATS.Net
// returns "connection refused" (or a timeout) fast. The endpoint should
// never throw; it should always return {ok, error?}.
var c = await LoggedIn();
var create = await c.PostAsJsonAsync("/api/connections", new
{
providerId = "nats", name = "test-fail",
config = new { url = "nats://127.0.0.1:1" }
});
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var id = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
var res = await c.PostAsync($"/api/connections/{id}/test", content: null);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
var body = await res.Content.ReadFromJsonAsync<JsonElement>();
Assert.False(body.GetProperty("ok").GetBoolean());
var err = body.GetProperty("error").GetString();
Assert.False(string.IsNullOrWhiteSpace(err));
}
private sealed record ConnectionDto(string Id, string ProviderId, string Name, JsonElement Config);
}