feat: show source inventory and rpc evidence
This commit is contained in:
@@ -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 { ConnectionHeader } from "../components/ConnectionHeader.js";
|
||||||
|
import { SourceInventory } from "../components/SourceInventory.js";
|
||||||
|
import { ProtocolEvidence } from "../components/ProtocolEvidence.js";
|
||||||
|
|
||||||
export function App() {
|
const parseSources = (
|
||||||
return (
|
data: unknown,
|
||||||
<main>
|
): SourceRecord[] => {
|
||||||
<h1>lda.chat Workflow Console</h1>
|
if (!data || typeof data !== "object") return [];
|
||||||
<ConnectionHeader />
|
const obj = data as Record<string, unknown>;
|
||||||
</main>
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ export type ConnectionPhase =
|
|||||||
| "rpc_error"
|
| "rpc_error"
|
||||||
| "malformed_response";
|
| "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 = {
|
export type ConnectionState = {
|
||||||
readonly phase: ConnectionPhase;
|
readonly phase: ConnectionPhase;
|
||||||
readonly draftTarget: string;
|
readonly draftTarget: string;
|
||||||
@@ -17,6 +39,10 @@ export type ConnectionState = {
|
|||||||
readonly storeRoot: string | null;
|
readonly storeRoot: string | null;
|
||||||
readonly durationMs: number | null;
|
readonly durationMs: number | null;
|
||||||
readonly message: string | 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";
|
export const STORAGE_KEY = "lda.workflowConsole.target";
|
||||||
@@ -50,13 +76,29 @@ export const initialState = (): ConnectionState => ({
|
|||||||
storeRoot: null,
|
storeRoot: null,
|
||||||
durationMs: null,
|
durationMs: null,
|
||||||
message: null,
|
message: null,
|
||||||
|
evidence: [],
|
||||||
|
sources: [],
|
||||||
|
sourcesLoading: false,
|
||||||
|
sourceError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ConnectionAction =
|
export type ConnectionAction =
|
||||||
| { readonly type: "submit"; readonly target: string }
|
| { readonly type: "submit"; readonly target: string }
|
||||||
| { readonly type: "success"; readonly data: ConnectionSuccess }
|
| { readonly type: "success"; readonly data: ConnectionSuccess }
|
||||||
| { readonly type: "failure"; readonly code: string; readonly message: string }
|
| { 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 = (
|
export const connectionReducer = (
|
||||||
state: ConnectionState,
|
state: ConnectionState,
|
||||||
@@ -69,6 +111,9 @@ export const connectionReducer = (
|
|||||||
phase: "connecting",
|
phase: "connecting",
|
||||||
draftTarget: action.target,
|
draftTarget: action.target,
|
||||||
message: null,
|
message: null,
|
||||||
|
sources: [],
|
||||||
|
sourceError: null,
|
||||||
|
sourcesLoading: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
case "success": {
|
case "success": {
|
||||||
@@ -84,6 +129,7 @@ export const connectionReducer = (
|
|||||||
storeRoot: action.data.connection.storeRoot,
|
storeRoot: action.data.connection.storeRoot,
|
||||||
durationMs: action.data.connection.durationMs,
|
durationMs: action.data.connection.durationMs,
|
||||||
message: null,
|
message: null,
|
||||||
|
sourcesLoading: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,9 +147,46 @@ export const connectionReducer = (
|
|||||||
...state,
|
...state,
|
||||||
draftTarget: action.value,
|
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 => {
|
const mapCodeToPhase = (code: string): ConnectionPhase => {
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case "invalid_target":
|
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 { render, screen, within, cleanup } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { ConnectionHeader } from "./ConnectionHeader.js";
|
import { ConnectionHeader } from "./ConnectionHeader.js";
|
||||||
|
import { initialState, connectionReducer } from "../app/state.js";
|
||||||
|
|
||||||
vi.mock("../connection/api.js", () => ({
|
const getFirstSection = () => {
|
||||||
connectToServer: vi.fn(),
|
const sections = document.querySelectorAll('section[aria-label="Connection"]');
|
||||||
}));
|
return sections[0] as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
import { connectToServer } from "../connection/api.js";
|
const successData = {
|
||||||
const mockConnect = vi.mocked(connectToServer);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cleanup();
|
|
||||||
mockConnect.mockReset();
|
|
||||||
try {
|
|
||||||
sessionStorage.clear();
|
|
||||||
} catch {
|
|
||||||
// jsdom may not provide sessionStorage
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const successResponse = {
|
|
||||||
ok: true,
|
ok: true,
|
||||||
connection: {
|
connection: {
|
||||||
status: "connected",
|
status: "connected",
|
||||||
@@ -33,14 +22,36 @@ const successResponse = {
|
|||||||
equivalentCli: "uv run wf status",
|
equivalentCli: "uv run wf status",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const getFirstSection = () => {
|
beforeEach(() => {
|
||||||
const sections = document.querySelectorAll('section[aria-label="Connection"]');
|
cleanup();
|
||||||
return sections[0] as HTMLElement;
|
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", () => {
|
describe("ConnectionHeader", () => {
|
||||||
it("renders default target and Connect button", () => {
|
it("renders default target and Connect button", () => {
|
||||||
render(<ConnectionHeader />);
|
renderWithDefaults();
|
||||||
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
|
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
|
||||||
"http://127.0.0.1:8765/rpc",
|
"http://127.0.0.1:8765/rpc",
|
||||||
);
|
);
|
||||||
@@ -50,23 +61,16 @@ describe("ConnectionHeader", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not automatically call the server", () => {
|
it("does not automatically call the server", () => {
|
||||||
render(<ConnectionHeader />);
|
const { onSubmit } = renderWithDefaults();
|
||||||
expect(mockConnect).not.toHaveBeenCalled();
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows connecting state and disables button during request", async () => {
|
it("shows connecting state and disables button during request", () => {
|
||||||
const user = userEvent.setup();
|
const connectingState = connectionReducer(initialState(), {
|
||||||
let resolveConnect!: (value: typeof successResponse) => void;
|
type: "submit",
|
||||||
mockConnect.mockReturnValue(
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
new Promise((r) => {
|
});
|
||||||
resolveConnect = r;
|
renderWithDefaults({ state: connectingState });
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<ConnectionHeader />);
|
|
||||||
await user.click(
|
|
||||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||||
@@ -74,18 +78,14 @@ describe("ConnectionHeader", () => {
|
|||||||
expect(
|
expect(
|
||||||
within(getFirstSection()).getByTestId("phase-label"),
|
within(getFirstSection()).getByTestId("phase-label"),
|
||||||
).toHaveTextContent("Connecting\u2026");
|
).toHaveTextContent("Connecting\u2026");
|
||||||
|
|
||||||
resolveConnect(successResponse);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows connected state with server details", async () => {
|
it("shows connected state with server details", () => {
|
||||||
const user = userEvent.setup();
|
const connectedState = connectionReducer(initialState(), {
|
||||||
mockConnect.mockResolvedValue(successResponse);
|
type: "success",
|
||||||
|
data: successData,
|
||||||
render(<ConnectionHeader />);
|
});
|
||||||
await user.click(
|
renderWithDefaults({ state: connectedState });
|
||||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
within(getFirstSection()).getByTestId("phase-label"),
|
within(getFirstSection()).getByTestId("phase-label"),
|
||||||
@@ -104,29 +104,38 @@ describe("ConnectionHeader", () => {
|
|||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retains typed value on failure", async () => {
|
it("retains typed value on failure", () => {
|
||||||
const user = userEvent.setup();
|
const errorState = connectionReducer(initialState(), {
|
||||||
mockConnect.mockResolvedValue({
|
type: "failure",
|
||||||
ok: false,
|
code: "invalid_target",
|
||||||
error: { code: "invalid_target", message: "bad target" },
|
message: "bad target",
|
||||||
exchange: { request: null, response: null },
|
|
||||||
});
|
});
|
||||||
|
renderWithDefaults({
|
||||||
render(<ConnectionHeader />);
|
state: { ...errorState, draftTarget: "http://bad:9999/rpc" },
|
||||||
const input = screen.getByLabelText("Workflow JSON-RPC URL");
|
});
|
||||||
await user.clear(input);
|
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
|
||||||
await user.type(input, "http://bad:9999/rpc");
|
"http://bad:9999/rpc",
|
||||||
await user.click(
|
|
||||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(input).toHaveValue("http://bad:9999/rpc");
|
|
||||||
expect(
|
expect(
|
||||||
within(getFirstSection()).getByTestId("error-message"),
|
within(getFirstSection()).getByTestId("error-message"),
|
||||||
).toHaveTextContent("bad target");
|
).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 {
|
try {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
"lda.workflowConsole.target",
|
"lda.workflowConsole.target",
|
||||||
@@ -135,8 +144,7 @@ describe("ConnectionHeader", () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// jsdom may not support sessionStorage
|
// jsdom may not support sessionStorage
|
||||||
}
|
}
|
||||||
|
const { onSubmit } = renderWithDefaults();
|
||||||
render(<ConnectionHeader />);
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
expect(mockConnect).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,35 +1,5 @@
|
|||||||
import { useReducer, type FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import {
|
import type { ConnectionState } from "../app/state.js";
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const phaseLabel = (phase: string): string => {
|
const phaseLabel = (phase: string): string => {
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
@@ -52,25 +22,27 @@ const phaseLabel = (phase: string): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConnectionHeader = () => {
|
type Props = {
|
||||||
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
|
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();
|
e.preventDefault();
|
||||||
void handleSubmit(dispatch, state.draftTarget);
|
onSubmit(state.draftTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Connection">
|
<section aria-label="Connection">
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<label htmlFor="target-input">Workflow JSON-RPC URL</label>
|
<label htmlFor="target-input">Workflow JSON-RPC URL</label>
|
||||||
<input
|
<input
|
||||||
id="target-input"
|
id="target-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={state.draftTarget}
|
value={state.draftTarget}
|
||||||
onChange={(e) =>
|
onChange={(e) => onDraftChange(e.target.value)}
|
||||||
dispatch({ type: "draft_changed", value: e.target.value })
|
|
||||||
}
|
|
||||||
disabled={state.phase === "connecting"}
|
disabled={state.phase === "connecting"}
|
||||||
/>
|
/>
|
||||||
<button type="submit" 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Vendored
+4
@@ -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";
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
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(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user