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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user