feat: show source inventory and rpc evidence
This commit is contained in:
@@ -2,25 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, within, cleanup } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionHeader } from "./ConnectionHeader.js";
|
||||
import { initialState, connectionReducer } from "../app/state.js";
|
||||
|
||||
vi.mock("../connection/api.js", () => ({
|
||||
connectToServer: vi.fn(),
|
||||
}));
|
||||
const getFirstSection = () => {
|
||||
const sections = document.querySelectorAll('section[aria-label="Connection"]');
|
||||
return sections[0] as HTMLElement;
|
||||
};
|
||||
|
||||
import { connectToServer } from "../connection/api.js";
|
||||
const mockConnect = vi.mocked(connectToServer);
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
mockConnect.mockReset();
|
||||
try {
|
||||
sessionStorage.clear();
|
||||
} catch {
|
||||
// jsdom may not provide sessionStorage
|
||||
}
|
||||
});
|
||||
|
||||
const successResponse = {
|
||||
const successData = {
|
||||
ok: true,
|
||||
connection: {
|
||||
status: "connected",
|
||||
@@ -33,14 +22,36 @@ const successResponse = {
|
||||
equivalentCli: "uv run wf status",
|
||||
} as const;
|
||||
|
||||
const getFirstSection = () => {
|
||||
const sections = document.querySelectorAll('section[aria-label="Connection"]');
|
||||
return sections[0] as HTMLElement;
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
try {
|
||||
sessionStorage.clear();
|
||||
} catch {
|
||||
// jsdom may not provide sessionStorage
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const renderWithDefaults = (overrides?: {
|
||||
onSubmit?: (target: string) => void;
|
||||
onDraftChange?: (value: string) => void;
|
||||
state?: ReturnType<typeof initialState>;
|
||||
}) => {
|
||||
const state = overrides?.state ?? initialState();
|
||||
const onSubmit = overrides?.onSubmit ?? vi.fn();
|
||||
const onDraftChange = overrides?.onDraftChange ?? vi.fn();
|
||||
render(
|
||||
<ConnectionHeader state={state} onSubmit={onSubmit} onDraftChange={onDraftChange} />,
|
||||
);
|
||||
return { onSubmit, onDraftChange, state };
|
||||
};
|
||||
|
||||
describe("ConnectionHeader", () => {
|
||||
it("renders default target and Connect button", () => {
|
||||
render(<ConnectionHeader />);
|
||||
renderWithDefaults();
|
||||
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
);
|
||||
@@ -50,23 +61,16 @@ describe("ConnectionHeader", () => {
|
||||
});
|
||||
|
||||
it("does not automatically call the server", () => {
|
||||
render(<ConnectionHeader />);
|
||||
expect(mockConnect).not.toHaveBeenCalled();
|
||||
const { onSubmit } = renderWithDefaults();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows connecting state and disables button during request", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveConnect!: (value: typeof successResponse) => void;
|
||||
mockConnect.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolveConnect = r;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ConnectionHeader />);
|
||||
await user.click(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
);
|
||||
it("shows connecting state and disables button during request", () => {
|
||||
const connectingState = connectionReducer(initialState(), {
|
||||
type: "submit",
|
||||
target: "http://127.0.0.1:8765/rpc",
|
||||
});
|
||||
renderWithDefaults({ state: connectingState });
|
||||
|
||||
expect(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
@@ -74,18 +78,14 @@ describe("ConnectionHeader", () => {
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("phase-label"),
|
||||
).toHaveTextContent("Connecting\u2026");
|
||||
|
||||
resolveConnect(successResponse);
|
||||
});
|
||||
|
||||
it("shows connected state with server details", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockConnect.mockResolvedValue(successResponse);
|
||||
|
||||
render(<ConnectionHeader />);
|
||||
await user.click(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
);
|
||||
it("shows connected state with server details", () => {
|
||||
const connectedState = connectionReducer(initialState(), {
|
||||
type: "success",
|
||||
data: successData,
|
||||
});
|
||||
renderWithDefaults({ state: connectedState });
|
||||
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("phase-label"),
|
||||
@@ -104,29 +104,38 @@ describe("ConnectionHeader", () => {
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("retains typed value on failure", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockConnect.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: "invalid_target", message: "bad target" },
|
||||
exchange: { request: null, response: null },
|
||||
it("retains typed value on failure", () => {
|
||||
const errorState = connectionReducer(initialState(), {
|
||||
type: "failure",
|
||||
code: "invalid_target",
|
||||
message: "bad target",
|
||||
});
|
||||
|
||||
render(<ConnectionHeader />);
|
||||
const input = screen.getByLabelText("Workflow JSON-RPC URL");
|
||||
await user.clear(input);
|
||||
await user.type(input, "http://bad:9999/rpc");
|
||||
await user.click(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
renderWithDefaults({
|
||||
state: { ...errorState, draftTarget: "http://bad:9999/rpc" },
|
||||
});
|
||||
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
|
||||
"http://bad:9999/rpc",
|
||||
);
|
||||
|
||||
expect(input).toHaveValue("http://bad:9999/rpc");
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("error-message"),
|
||||
).toHaveTextContent("bad target");
|
||||
});
|
||||
|
||||
it("restored target still requires explicit connect", async () => {
|
||||
it("calls onSubmit with target when form submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
renderWithDefaults({ onSubmit });
|
||||
|
||||
await user.click(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
);
|
||||
});
|
||||
|
||||
it("restored target still requires explicit connect", () => {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"lda.workflowConsole.target",
|
||||
@@ -135,8 +144,7 @@ describe("ConnectionHeader", () => {
|
||||
} catch {
|
||||
// jsdom may not support sessionStorage
|
||||
}
|
||||
|
||||
render(<ConnectionHeader />);
|
||||
expect(mockConnect).not.toHaveBeenCalled();
|
||||
const { onSubmit } = renderWithDefaults();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
import { useReducer, type FormEvent } from "react";
|
||||
import {
|
||||
connectionReducer,
|
||||
initialState,
|
||||
type ConnectionAction,
|
||||
} from "../app/state.js";
|
||||
import { connectToServer } from "../connection/api.js";
|
||||
|
||||
const handleSubmit = async (
|
||||
dispatch: React.Dispatch<ConnectionAction>,
|
||||
target: string,
|
||||
) => {
|
||||
dispatch({ type: "submit", target });
|
||||
try {
|
||||
const response = await connectToServer(target);
|
||||
if (response.ok) {
|
||||
dispatch({ type: "success", data: response });
|
||||
} else {
|
||||
dispatch({
|
||||
type: "failure",
|
||||
code: response.error.code,
|
||||
message: response.error.message,
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
dispatch({
|
||||
type: "failure",
|
||||
code: "rpc_protocol_error",
|
||||
message: e instanceof Error ? e.message : "unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
import type { FormEvent } from "react";
|
||||
import type { ConnectionState } from "../app/state.js";
|
||||
|
||||
const phaseLabel = (phase: string): string => {
|
||||
switch (phase) {
|
||||
@@ -52,25 +22,27 @@ const phaseLabel = (phase: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectionHeader = () => {
|
||||
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
|
||||
type Props = {
|
||||
readonly state: ConnectionState;
|
||||
readonly onSubmit: (target: string) => void;
|
||||
readonly onDraftChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
export const ConnectionHeader = ({ state, onSubmit, onDraftChange }: Props) => {
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit(dispatch, state.draftTarget);
|
||||
onSubmit(state.draftTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Connection">
|
||||
<form onSubmit={onSubmit}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="target-input">Workflow JSON-RPC URL</label>
|
||||
<input
|
||||
id="target-input"
|
||||
type="text"
|
||||
value={state.draftTarget}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "draft_changed", value: e.target.value })
|
||||
}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
disabled={state.phase === "connecting"}
|
||||
/>
|
||||
<button type="submit" disabled={state.phase === "connecting"}>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, within, cleanup } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ProtocolEvidence } from "./ProtocolEvidence.js";
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeRecord = (
|
||||
overrides: Partial<EvidenceRecord> & { id: string },
|
||||
): EvidenceRecord => ({
|
||||
operation: "workflow.health",
|
||||
label: "Health check",
|
||||
equivalentCli: "uv run wf status",
|
||||
request: { method: "workflow.health" },
|
||||
response: { status: "ok" },
|
||||
durationMs: 12,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ProtocolEvidence", () => {
|
||||
it("shows empty state when no evidence", () => {
|
||||
render(<ProtocolEvidence evidence={[]} />);
|
||||
expect(screen.getByTestId("evidence-empty")).toHaveTextContent(
|
||||
"No evidence recorded yet.",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists evidence records as selectable toggles", () => {
|
||||
const evidence = [
|
||||
makeRecord({ id: "health-1", operation: "workflow.health" }),
|
||||
makeRecord({ id: "sources-1", operation: "workflow.sources.list" }),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
expect(screen.getByTestId("evidence-toggle-health-1")).toBeDefined();
|
||||
expect(screen.getByTestId("evidence-toggle-sources-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("expands to show equivalent CLI, request, and response", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({
|
||||
id: "health-1",
|
||||
operation: "workflow.health",
|
||||
equivalentCli: "uv run wf status",
|
||||
request: { jsonrpc: "2.0", method: "workflow.health" },
|
||||
response: { status: "ok", storeRoot: "/tmp" },
|
||||
durationMs: 8,
|
||||
}),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-health-1"));
|
||||
|
||||
const detail = screen.getByTestId("evidence-detail-health-1");
|
||||
expect(detail).toBeDefined();
|
||||
|
||||
const cliSection = within(detail).getAllByRole("heading", {
|
||||
name: "Equivalent CLI",
|
||||
});
|
||||
expect(cliSection).toHaveLength(1);
|
||||
expect(detail.textContent).toContain("uv run wf status");
|
||||
|
||||
const preElements = detail.querySelectorAll("pre code");
|
||||
expect(preElements.length).toBeGreaterThanOrEqual(2);
|
||||
expect(preElements[0]?.textContent).toContain("uv run wf status");
|
||||
expect(preElements[1]?.textContent).toContain("workflow.health");
|
||||
});
|
||||
|
||||
it("shows 'No response received.' for null response", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({ id: "health-1", response: null }),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-health-1"));
|
||||
|
||||
const detail = screen.getByTestId("evidence-detail-health-1");
|
||||
expect(detail.textContent).toContain("No response received.");
|
||||
});
|
||||
|
||||
it("renders evidence through pre/code elements, never HTML", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({
|
||||
id: "health-1",
|
||||
request: { html: "<script>alert('xss')</script>" },
|
||||
response: null,
|
||||
}),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-health-1"));
|
||||
|
||||
const detail = screen.getByTestId("evidence-detail-health-1");
|
||||
const preCodes = detail.querySelectorAll("pre code");
|
||||
expect(preCodes.length).toBeGreaterThanOrEqual(2);
|
||||
const requestCode = preCodes[1] as Element;
|
||||
expect(requestCode.textContent).toContain("<script>");
|
||||
expect(requestCode.innerHTML).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("is keyboard operable with native button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({ id: "health-1" }),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
const toggle = screen.getByTestId("evidence-toggle-health-1");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.tab();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("collapses previous when opening another", async () => {
|
||||
const user = userEvent.setup();
|
||||
const evidence = [
|
||||
makeRecord({ id: "health-1", operation: "workflow.health" }),
|
||||
makeRecord({ id: "sources-1", operation: "workflow.sources.list" }),
|
||||
];
|
||||
render(<ProtocolEvidence evidence={evidence} />);
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-health-1"));
|
||||
expect(screen.getByTestId("evidence-detail-health-1")).toBeDefined();
|
||||
|
||||
await user.click(screen.getByTestId("evidence-toggle-sources-1"));
|
||||
expect(screen.getByTestId("evidence-detail-sources-1")).toBeDefined();
|
||||
expect(screen.queryByTestId("evidence-detail-health-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
|
||||
type Props = {
|
||||
readonly evidence: ReadonlyArray<EvidenceRecord>;
|
||||
};
|
||||
|
||||
export const ProtocolEvidence = ({ evidence }: Props) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Protocol Evidence">
|
||||
<h2>Protocol Evidence</h2>
|
||||
{evidence.length === 0 ? (
|
||||
<p data-testid="evidence-empty">No evidence recorded yet.</p>
|
||||
) : (
|
||||
<ul data-testid="evidence-list" className="evidence-list">
|
||||
{evidence.map((record) => (
|
||||
<li key={record.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expandedId === record.id}
|
||||
data-testid={`evidence-toggle-${record.id}`}
|
||||
onClick={() => toggle(record.id)}
|
||||
className="evidence-toggle"
|
||||
>
|
||||
<span className="evidence-op">{record.operation}</span>
|
||||
<span className="evidence-label">{record.label}</span>
|
||||
<span className="evidence-duration">
|
||||
{record.durationMs}ms
|
||||
</span>
|
||||
</button>
|
||||
{expandedId === record.id && (
|
||||
<div
|
||||
data-testid={`evidence-detail-${record.id}`}
|
||||
className="evidence-detail"
|
||||
>
|
||||
<div className="evidence-field">
|
||||
<h3>Equivalent CLI</h3>
|
||||
<pre>
|
||||
<code>{record.equivalentCli}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="evidence-field">
|
||||
<h3>Request</h3>
|
||||
<pre>
|
||||
<code>{JSON.stringify(record.request, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="evidence-field">
|
||||
<h3>Response</h3>
|
||||
<pre>
|
||||
<code>
|
||||
{record.response !== null
|
||||
? JSON.stringify(record.response, null, 2)
|
||||
: "No response received."}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, within, cleanup } from "@testing-library/react";
|
||||
import { SourceInventory } from "./SourceInventory.js";
|
||||
import type { SourceRecord } from "../app/state.js";
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeSource = (overrides: Partial<SourceRecord> & { id: string }): SourceRecord => ({
|
||||
kind: "tool",
|
||||
enabled: true,
|
||||
description: null,
|
||||
toolCount: 1,
|
||||
nodeSpecCount: 0,
|
||||
reducerCount: 0,
|
||||
promptCount: 0,
|
||||
resourceCount: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("SourceInventory", () => {
|
||||
it("shows loading state", () => {
|
||||
render(<SourceInventory sources={[]} loading={true} error={null} />);
|
||||
expect(screen.getByTestId("sources-loading")).toHaveTextContent(
|
||||
"Loading sources\u2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows empty state when no sources", () => {
|
||||
render(<SourceInventory sources={[]} loading={false} error={null} />);
|
||||
expect(screen.getByTestId("sources-empty")).toHaveTextContent(
|
||||
"No workflow sources reported.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders source rows with id, kind, and enabled status", () => {
|
||||
const sources = [
|
||||
makeSource({ id: "tools-core", kind: "tool", enabled: true }),
|
||||
makeSource({ id: "reducers-main", kind: "reducer", enabled: false }),
|
||||
];
|
||||
render(<SourceInventory sources={sources} loading={false} error={null} />);
|
||||
|
||||
expect(screen.getByTestId("source-id-tools-core")).toHaveTextContent(
|
||||
"tools-core",
|
||||
);
|
||||
expect(screen.getByTestId("source-kind-tools-core")).toHaveTextContent(
|
||||
"tool",
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("source-status-tools-core"),
|
||||
).toHaveTextContent("enabled");
|
||||
expect(
|
||||
screen.getByTestId("source-status-reducers-main"),
|
||||
).toHaveTextContent("disabled");
|
||||
});
|
||||
|
||||
it("shows description when present", () => {
|
||||
const sources = [
|
||||
makeSource({
|
||||
id: "tools-core",
|
||||
description: "Core workflow tools",
|
||||
}),
|
||||
];
|
||||
render(<SourceInventory sources={sources} loading={false} error={null} />);
|
||||
expect(screen.getByText("Core workflow tools")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders total counts across categories", () => {
|
||||
const sources = [
|
||||
makeSource({
|
||||
id: "tools-core",
|
||||
toolCount: 5,
|
||||
nodeSpecCount: 3,
|
||||
reducerCount: 2,
|
||||
promptCount: 1,
|
||||
resourceCount: 4,
|
||||
}),
|
||||
];
|
||||
render(<SourceInventory sources={sources} loading={false} error={null} />);
|
||||
|
||||
expect(screen.getByTestId("source-tools-tools-core")).toHaveTextContent("5");
|
||||
expect(
|
||||
screen.getByTestId("source-nodes-tools-core"),
|
||||
).toHaveTextContent("3");
|
||||
expect(
|
||||
screen.getByTestId("source-reducers-tools-core"),
|
||||
).toHaveTextContent("2");
|
||||
expect(
|
||||
screen.getByTestId("source-prompts-tools-core"),
|
||||
).toHaveTextContent("1");
|
||||
expect(
|
||||
screen.getByTestId("source-resources-tools-core"),
|
||||
).toHaveTextContent("4");
|
||||
});
|
||||
|
||||
it("renders error without replacing connection status", () => {
|
||||
render(
|
||||
<SourceInventory
|
||||
sources={[]}
|
||||
loading={false}
|
||||
error="Failed to load sources"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("sources-error")).toHaveTextContent(
|
||||
"Failed to load sources",
|
||||
);
|
||||
expect(screen.getByTestId("sources-error")).toHaveAttribute(
|
||||
"role",
|
||||
"alert",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SourceRecord } from "../app/state.js";
|
||||
|
||||
type Props = {
|
||||
readonly sources: ReadonlyArray<SourceRecord>;
|
||||
readonly loading: boolean;
|
||||
readonly error: string | null;
|
||||
};
|
||||
|
||||
export const SourceInventory = ({ sources, loading, error }: Props) => {
|
||||
return (
|
||||
<section aria-label="Source Inventory">
|
||||
<h2>Sources</h2>
|
||||
{loading && <p data-testid="sources-loading">Loading sources{"\u2026"}</p>}
|
||||
{error && (
|
||||
<p data-testid="sources-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && sources.length === 0 && (
|
||||
<p data-testid="sources-empty">No workflow sources reported.</p>
|
||||
)}
|
||||
{!loading && !error && sources.length > 0 && (
|
||||
<table data-testid="sources-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Kind</th>
|
||||
<th>Description</th>
|
||||
<th>Status</th>
|
||||
<th>Tools</th>
|
||||
<th>Nodes</th>
|
||||
<th>Reducers</th>
|
||||
<th>Prompts</th>
|
||||
<th>Resources</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((s) => (
|
||||
<tr key={s.id} data-testid={`source-row-${s.id}`}>
|
||||
<td data-testid={`source-id-${s.id}`}>{s.id}</td>
|
||||
<td data-testid={`source-kind-${s.id}`}>{s.kind}</td>
|
||||
<td data-testid={`source-desc-${s.id}`}>
|
||||
{s.description ?? ""}
|
||||
</td>
|
||||
<td data-testid={`source-status-${s.id}`}>
|
||||
{s.enabled ? "enabled" : "disabled"}
|
||||
</td>
|
||||
<td data-testid={`source-tools-${s.id}`}>{s.toolCount}</td>
|
||||
<td data-testid={`source-nodes-${s.id}`}>{s.nodeSpecCount}</td>
|
||||
<td data-testid={`source-reducers-${s.id}`}>
|
||||
{s.reducerCount}
|
||||
</td>
|
||||
<td data-testid={`source-prompts-${s.id}`}>{s.promptCount}</td>
|
||||
<td data-testid={`source-resources-${s.id}`}>
|
||||
{s.resourceCount}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user