feat: show source inventory and rpc evidence

This commit is contained in:
lda
2026-07-02 12:34:20 +07:00 Verified
parent 4d95b22e0b
commit 6c7522123d
11 changed files with 1041 additions and 114 deletions
+140 -7
View File
@@ -1,10 +1,143 @@
import { useReducer, useEffect, useCallback } from "react";
import {
connectionReducer,
initialState,
type SourceRecord,
} from "./state.js";
import { connectToServer, callOperation } from "../connection/api.js";
import { ConnectionHeader } from "../components/ConnectionHeader.js";
import { SourceInventory } from "../components/SourceInventory.js";
import { ProtocolEvidence } from "../components/ProtocolEvidence.js";
export function App() {
return (
<main>
<h1>lda.chat Workflow Console</h1>
<ConnectionHeader />
</main>
const parseSources = (
data: unknown,
): SourceRecord[] => {
if (!data || typeof data !== "object") return [];
const obj = data as Record<string, unknown>;
if (!Array.isArray(obj.sources)) return [];
return obj.sources.map((entry: unknown, i: number) => {
const s = entry as Record<string, unknown>;
const id = typeof s.id === "string" ? s.id : `source-${i}`;
const kind = typeof s.kind === "string" ? s.kind : "unknown";
const enabled = s.enabled !== false;
const description =
typeof s.description === "string" ? s.description : null;
const counts = (s.counts ?? {}) as Record<string, number>;
return {
id,
kind,
enabled,
description,
toolCount: typeof counts.tools === "number" ? counts.tools : 0,
nodeSpecCount: typeof counts.nodeSpecs === "number" ? counts.nodeSpecs : 0,
reducerCount: typeof counts.reducers === "number" ? counts.reducers : 0,
promptCount: typeof counts.prompts === "number" ? counts.prompts : 0,
resourceCount:
typeof counts.resources === "number" ? counts.resources : 0,
};
});
};
export const App = () => {
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
const loadSources = useCallback(
async (target: string) => {
const result = await callOperation(
"workflow.sources.list",
target,
{ limit: 50 },
);
if (result.ok) {
const sources = parseSources(result.interpreted);
dispatch({
type: "sources_loaded",
sources,
evidence: {
id: `sources-${Date.now()}`,
operation: "workflow.sources.list",
label: "Source inventory",
equivalentCli: "uv run wf sources list --limit 50",
request: result.exchange.request,
response: result.exchange.response,
durationMs: result.durationMs,
},
});
} else {
dispatch({
type: "sources_error",
message: result.error.message,
evidence: {
id: `sources-${Date.now()}`,
operation: "workflow.sources.list",
label: "Source inventory",
equivalentCli: "uv run wf sources list --limit 50",
request: result.exchange.request,
response: result.exchange.response,
durationMs: 0,
},
});
}
},
[],
);
}
useEffect(() => {
if (state.phase === "connected" && state.connectedTarget) {
void loadSources(state.connectedTarget);
}
}, [state.phase, state.connectedTarget, loadSources]);
const onSubmit = (target: string) => {
dispatch({ type: "submit", target });
void connectToServer(target).then(
(response) => {
if (response.ok) {
dispatch({ type: "success", data: response });
dispatch({
type: "evidence_recorded",
record: {
id: `health-${Date.now()}`,
operation: "workflow.health",
label: "Health check",
equivalentCli: "uv run wf status",
request: response.exchange.request,
response: response.exchange.response,
durationMs: response.connection.durationMs,
},
});
} else {
dispatch({
type: "failure",
code: response.error.code,
message: response.error.message,
});
}
},
(e: unknown) => {
dispatch({
type: "failure",
code: "rpc_protocol_error",
message: e instanceof Error ? e.message : "unknown error",
});
},
);
};
return (
<div className="app-layout">
<ConnectionHeader
state={state}
onSubmit={onSubmit}
onDraftChange={(value) => dispatch({ type: "draft_changed", value })}
/>
<SourceInventory
sources={state.sources}
loading={state.sourcesLoading}
error={state.sourceError}
/>
<ProtocolEvidence evidence={state.evidence} />
</div>
);
};
+84 -1
View File
@@ -9,6 +9,28 @@ export type ConnectionPhase =
| "rpc_error"
| "malformed_response";
export type EvidenceRecord = {
readonly id: string;
readonly operation: string;
readonly label: string;
readonly equivalentCli: string;
readonly request: unknown;
readonly response: unknown;
readonly durationMs: number;
};
export type SourceRecord = {
readonly id: string;
readonly kind: string;
readonly enabled: boolean;
readonly description: string | null;
readonly toolCount: number;
readonly nodeSpecCount: number;
readonly reducerCount: number;
readonly promptCount: number;
readonly resourceCount: number;
};
export type ConnectionState = {
readonly phase: ConnectionPhase;
readonly draftTarget: string;
@@ -17,6 +39,10 @@ export type ConnectionState = {
readonly storeRoot: string | null;
readonly durationMs: number | null;
readonly message: string | null;
readonly evidence: ReadonlyArray<EvidenceRecord>;
readonly sources: ReadonlyArray<SourceRecord>;
readonly sourcesLoading: boolean;
readonly sourceError: string | null;
};
export const STORAGE_KEY = "lda.workflowConsole.target";
@@ -50,13 +76,29 @@ export const initialState = (): ConnectionState => ({
storeRoot: null,
durationMs: null,
message: null,
evidence: [],
sources: [],
sourcesLoading: false,
sourceError: null,
});
export type ConnectionAction =
| { readonly type: "submit"; readonly target: string }
| { readonly type: "success"; readonly data: ConnectionSuccess }
| { readonly type: "failure"; readonly code: string; readonly message: string }
| { readonly type: "draft_changed"; readonly value: string };
| { readonly type: "draft_changed"; readonly value: string }
| { readonly type: "sources_loading" }
| {
readonly type: "sources_loaded";
readonly sources: ReadonlyArray<SourceRecord>;
readonly evidence: EvidenceRecord;
}
| {
readonly type: "sources_error";
readonly message: string;
readonly evidence?: EvidenceRecord;
}
| { readonly type: "evidence_recorded"; readonly record: EvidenceRecord };
export const connectionReducer = (
state: ConnectionState,
@@ -69,6 +111,9 @@ export const connectionReducer = (
phase: "connecting",
draftTarget: action.target,
message: null,
sources: [],
sourceError: null,
sourcesLoading: false,
};
case "success": {
@@ -84,6 +129,7 @@ export const connectionReducer = (
storeRoot: action.data.connection.storeRoot,
durationMs: action.data.connection.durationMs,
message: null,
sourcesLoading: true,
};
}
@@ -101,9 +147,46 @@ export const connectionReducer = (
...state,
draftTarget: action.value,
};
case "sources_loading":
return {
...state,
sourcesLoading: true,
sourceError: null,
};
case "sources_loaded":
return {
...state,
sources: action.sources,
sourcesLoading: false,
sourceError: null,
evidence: appendEvidence(state.evidence, action.evidence),
};
case "sources_error":
return {
...state,
sourcesLoading: false,
sourceError: action.message,
evidence: action.evidence
? appendEvidence(state.evidence, action.evidence)
: state.evidence,
};
case "evidence_recorded":
return {
...state,
evidence: appendEvidence(state.evidence, action.record),
};
}
};
const appendEvidence = (
existing: ReadonlyArray<EvidenceRecord>,
record: EvidenceRecord,
): ReadonlyArray<EvidenceRecord> => [...existing, record];
const mapCodeToPhase = (code: string): ConnectionPhase => {
switch (code) {
case "invalid_target":
@@ -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>
);
};
+4
View File
@@ -0,0 +1,4 @@
declare module "@fontsource-variable/source-sans-3";
declare module "@fontsource/barlow-condensed/600.css";
declare module "@fontsource/barlow-condensed/700.css";
declare module "@fontsource/ibm-plex-mono/400.css";
+6 -1
View File
@@ -1,6 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app/App";
import "@fontsource/barlow-condensed/600.css";
import "@fontsource/barlow-condensed/700.css";
import "@fontsource-variable/source-sans-3";
import "@fontsource/ibm-plex-mono/400.css";
import "./styles/global.css";
import { App } from "./app/App.js";
createRoot(document.getElementById("root")!).render(
<StrictMode>
+324
View File
@@ -0,0 +1,324 @@
:root {
--color-paper: #faf8f5;
--color-ink: #1a1a1a;
--color-slate: #5a5a5a;
--color-signal-green: #2d8a4e;
--color-amber: #b8860b;
--color-red: #c0392b;
--color-border: #d4d0cb;
--color-surface: #f0ede8;
--font-heading: "Barlow Condensed", sans-serif;
--font-body: "Source Sans 3", sans-serif;
--font-mono: "IBM Plex Mono", monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: var(--font-body);
color: var(--color-ink);
background-color: var(--color-paper);
line-height: 1.5;
background-image: linear-gradient(
rgba(0, 0, 0, 0.015) 1px,
transparent 1px
),
linear-gradient(90deg, rgba(0, 0, 0, 0.015) 1px, transparent 1px);
background-size: 2rem 2rem;
}
body {
margin: 0;
padding: 1.5rem;
}
h1,
h2,
h3 {
font-family: var(--font-heading);
font-weight: 700;
margin: 0 0 0.5rem 0;
letter-spacing: 0.02em;
text-transform: uppercase;
}
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.25rem;
border-bottom: 2px solid var(--color-border);
padding-bottom: 0.25rem;
margin-bottom: 0.75rem;
}
h3 {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-slate);
margin-bottom: 0.25rem;
}
section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
}
/* Connection form */
form {
display: flex;
gap: 0.5rem;
align-items: end;
flex-wrap: wrap;
}
label {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-slate);
flex-basis: 100%;
}
input[type="text"] {
flex: 1 1 20rem;
padding: 0.5rem 0.75rem;
font-family: var(--font-mono);
font-size: 0.85rem;
border: 1px solid var(--color-border);
border-radius: 3px;
background: #fff;
color: var(--color-ink);
}
input[type="text"]:focus {
outline: 2px solid var(--color-signal-green);
outline-offset: -1px;
}
button {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.5rem 1.25rem;
border: 1px solid var(--color-ink);
border-radius: 3px;
background: var(--color-ink);
color: var(--color-paper);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
button:hover:not(:disabled) {
background: var(--color-slate);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status bar */
[role="status"] {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--color-slate);
}
/* Source inventory table */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
thead th {
font-family: var(--font-heading);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-slate);
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 2px solid var(--color-border);
}
tbody td {
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--color-border);
}
tbody tr:hover {
background: rgba(0, 0, 0, 0.02);
}
/* Evidence drawer */
.evidence-list {
list-style: none;
padding: 0;
margin: 0;
}
.evidence-list li {
border-bottom: 1px solid var(--color-border);
}
.evidence-list li:last-child {
border-bottom: none;
}
.evidence-toggle {
width: 100%;
display: flex;
gap: 0.75rem;
align-items: center;
padding: 0.5rem 0;
background: none;
border: none;
border-radius: 0;
color: var(--color-ink);
font-family: var(--font-body);
font-size: 0.85rem;
text-transform: none;
letter-spacing: normal;
cursor: pointer;
text-align: left;
}
.evidence-toggle:hover {
background: rgba(0, 0, 0, 0.02);
}
.evidence-op {
font-family: var(--font-mono);
font-weight: 600;
min-width: 12rem;
}
.evidence-label {
flex: 1;
color: var(--color-slate);
}
.evidence-duration {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--color-slate);
}
.evidence-detail {
padding: 0.75rem;
background: var(--color-paper);
border: 1px solid var(--color-border);
border-radius: 3px;
margin-bottom: 0.5rem;
animation: fadeIn 0.15s ease;
}
.evidence-field {
margin-bottom: 0.75rem;
}
.evidence-field:last-child {
margin-bottom: 0;
}
.evidence-field pre {
margin: 0;
padding: 0.5rem;
background: var(--color-ink);
color: var(--color-paper);
border-radius: 3px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.8rem;
line-height: 1.4;
}
/* Layout grid */
.app-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(22rem, 0.7fr);
gap: 1rem;
align-items: start;
}
.app-layout > section[aria-label="Connection"] {
grid-column: 1 / -1;
}
/* Motion */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes sourceReveal {
from {
opacity: 0;
transform: translateX(-4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
tbody tr {
animation: sourceReveal 0.15s ease both;
}
tbody tr:nth-child(1) { animation-delay: 0ms; }
tbody tr:nth-child(2) { animation-delay: 30ms; }
tbody tr:nth-child(3) { animation-delay: 60ms; }
tbody tr:nth-child(4) { animation-delay: 90ms; }
tbody tr:nth-child(5) { animation-delay: 120ms; }
tbody tr:nth-child(6) { animation-delay: 150ms; }
tbody tr:nth-child(7) { animation-delay: 180ms; }
tbody tr:nth-child(8) { animation-delay: 210ms; }
tbody tr:nth-child(9) { animation-delay: 240ms; }
tbody tr:nth-child(10) { animation-delay: 270ms; }
@media (prefers-reduced-motion: reduce) {
.evidence-detail,
tbody tr {
animation: none;
}
}
/* Responsive */
@media (max-width: 850px) {
.app-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
form {
flex-direction: column;
align-items: stretch;
}
input[type="text"] {
flex-basis: auto;
}
}