mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-06-27 18:50:41 +00:00
feat(ui): add infinite scroll pagination to chat completions/responses logs table (#2466)
Some checks failed
Integration Auth Tests / test-matrix (oauth2_token) (push) Failing after 2s
Integration Tests / test-matrix (http, 3.10, inspect) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, providers) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, datasets) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, post_training) (push) Failing after 6s
Integration Tests / test-matrix (http, 3.11, datasets) (push) Failing after 4s
Integration Tests / test-matrix (http, 3.10, tool_runtime) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.11, agents) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.11, inference) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, vector_io) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.11, inspect) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.11, post_training) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.11, tool_runtime) (push) Failing after 6s
Integration Tests / test-matrix (http, 3.10, agents) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.11, vector_io) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.12, inspect) (push) Failing after 6s
Integration Tests / test-matrix (http, 3.11, scoring) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.12, datasets) (push) Failing after 9s
Integration Tests / test-matrix (http, 3.12, inference) (push) Failing after 8s
Integration Tests / test-matrix (http, 3.10, scoring) (push) Failing after 8s
Integration Tests / test-matrix (http, 3.10, inference) (push) Failing after 11s
Integration Tests / test-matrix (http, 3.12, post_training) (push) Failing after 8s
Integration Tests / test-matrix (http, 3.12, tool_runtime) (push) Failing after 9s
Integration Tests / test-matrix (http, 3.12, agents) (push) Failing after 11s
Integration Tests / test-matrix (http, 3.12, scoring) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.12, vector_io) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.12, providers) (push) Failing after 9s
Integration Tests / test-matrix (library, 3.10, agents) (push) Failing after 9s
Integration Tests / test-matrix (library, 3.10, datasets) (push) Failing after 10s
Integration Tests / test-matrix (library, 3.10, inference) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.10, providers) (push) Failing after 10s
Integration Tests / test-matrix (library, 3.10, post_training) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.11, datasets) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.10, scoring) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.11, providers) (push) Failing after 11s
Integration Tests / test-matrix (library, 3.10, inspect) (push) Failing after 10s
Integration Tests / test-matrix (library, 3.10, vector_io) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.10, tool_runtime) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.11, agents) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.11, post_training) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.11, inspect) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.11, inference) (push) Failing after 7s
Integration Tests / test-matrix (library, 3.11, tool_runtime) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.11, providers) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.11, scoring) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.11, vector_io) (push) Failing after 7s
Integration Tests / test-matrix (library, 3.12, datasets) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.12, agents) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.12, inference) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, inspect) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, post_training) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, providers) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.12, scoring) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, tool_runtime) (push) Failing after 5s
Test External Providers / test-external-providers (venv) (push) Failing after 16s
Integration Tests / test-matrix (library, 3.12, vector_io) (push) Failing after 20s
Unit Tests / unit-tests (3.11) (push) Failing after 16s
Unit Tests / unit-tests (3.13) (push) Failing after 14s
Unit Tests / unit-tests (3.10) (push) Failing after 48s
Unit Tests / unit-tests (3.12) (push) Failing after 46s
Pre-commit / pre-commit (push) Successful in 1m23s
Some checks failed
Integration Auth Tests / test-matrix (oauth2_token) (push) Failing after 2s
Integration Tests / test-matrix (http, 3.10, inspect) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, providers) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, datasets) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, post_training) (push) Failing after 6s
Integration Tests / test-matrix (http, 3.11, datasets) (push) Failing after 4s
Integration Tests / test-matrix (http, 3.10, tool_runtime) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.11, agents) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.11, inference) (push) Failing after 5s
Integration Tests / test-matrix (http, 3.10, vector_io) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.11, inspect) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.11, post_training) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.11, tool_runtime) (push) Failing after 6s
Integration Tests / test-matrix (http, 3.10, agents) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.11, vector_io) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.12, inspect) (push) Failing after 6s
Integration Tests / test-matrix (http, 3.11, scoring) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.12, datasets) (push) Failing after 9s
Integration Tests / test-matrix (http, 3.12, inference) (push) Failing after 8s
Integration Tests / test-matrix (http, 3.10, scoring) (push) Failing after 8s
Integration Tests / test-matrix (http, 3.10, inference) (push) Failing after 11s
Integration Tests / test-matrix (http, 3.12, post_training) (push) Failing after 8s
Integration Tests / test-matrix (http, 3.12, tool_runtime) (push) Failing after 9s
Integration Tests / test-matrix (http, 3.12, agents) (push) Failing after 11s
Integration Tests / test-matrix (http, 3.12, scoring) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.12, vector_io) (push) Failing after 7s
Integration Tests / test-matrix (http, 3.12, providers) (push) Failing after 9s
Integration Tests / test-matrix (library, 3.10, agents) (push) Failing after 9s
Integration Tests / test-matrix (library, 3.10, datasets) (push) Failing after 10s
Integration Tests / test-matrix (library, 3.10, inference) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.10, providers) (push) Failing after 10s
Integration Tests / test-matrix (library, 3.10, post_training) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.11, datasets) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.10, scoring) (push) Failing after 10s
Integration Tests / test-matrix (http, 3.11, providers) (push) Failing after 11s
Integration Tests / test-matrix (library, 3.10, inspect) (push) Failing after 10s
Integration Tests / test-matrix (library, 3.10, vector_io) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.10, tool_runtime) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.11, agents) (push) Failing after 8s
Integration Tests / test-matrix (library, 3.11, post_training) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.11, inspect) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.11, inference) (push) Failing after 7s
Integration Tests / test-matrix (library, 3.11, tool_runtime) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.11, providers) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.11, scoring) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.11, vector_io) (push) Failing after 7s
Integration Tests / test-matrix (library, 3.12, datasets) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.12, agents) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.12, inference) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, inspect) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, post_training) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, providers) (push) Failing after 6s
Integration Tests / test-matrix (library, 3.12, scoring) (push) Failing after 5s
Integration Tests / test-matrix (library, 3.12, tool_runtime) (push) Failing after 5s
Test External Providers / test-external-providers (venv) (push) Failing after 16s
Integration Tests / test-matrix (library, 3.12, vector_io) (push) Failing after 20s
Unit Tests / unit-tests (3.11) (push) Failing after 16s
Unit Tests / unit-tests (3.13) (push) Failing after 14s
Unit Tests / unit-tests (3.10) (push) Failing after 48s
Unit Tests / unit-tests (3.12) (push) Failing after 46s
Pre-commit / pre-commit (push) Successful in 1m23s
## Summary: This commit adds infinite scroll pagination to the chat completions and responses tables. ## Test Plan: 1. Run unit tests: npm run test 2. Manual testing: Navigate to chat completions/responses pages 3. Verify infinite scroll triggers when approaching bottom 4. Added playwright tests: npm run test:e2e
This commit is contained in:
parent
90d03552d4
commit
e6bfc717cb
20 changed files with 1145 additions and 388 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -26,3 +26,5 @@ venv/
|
||||||
pytest-report.xml
|
pytest-report.xml
|
||||||
.coverage
|
.coverage
|
||||||
.python-version
|
.python-version
|
||||||
|
CLAUDE.md
|
||||||
|
.claude/
|
||||||
|
|
3
llama_stack/ui/.gitignore
vendored
3
llama_stack/ui/.gitignore
vendored
|
@ -39,3 +39,6 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# playwright
|
||||||
|
.last-run.json
|
||||||
|
|
|
@ -1,51 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ChatCompletion } from "@/lib/types";
|
|
||||||
import { ChatCompletionsTable } from "@/components/chat-completions/chat-completions-table";
|
import { ChatCompletionsTable } from "@/components/chat-completions/chat-completions-table";
|
||||||
import { client } from "@/lib/client";
|
|
||||||
|
|
||||||
export default function ChatCompletionsPage() {
|
export default function ChatCompletionsPage() {
|
||||||
const [completions, setCompletions] = useState<ChatCompletion[]>([]);
|
return <ChatCompletionsTable paginationOptions={{ limit: 20 }} />;
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchCompletions = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await client.chat.completions.list();
|
|
||||||
const data = Array.isArray(response)
|
|
||||||
? response
|
|
||||||
: (response as { data: ChatCompletion[] }).data;
|
|
||||||
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
setCompletions(data);
|
|
||||||
} else {
|
|
||||||
console.error("Unexpected response structure:", response);
|
|
||||||
setError(new Error("Unexpected response structure"));
|
|
||||||
setCompletions([]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error fetching chat completions:", err);
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err : new Error("Failed to fetch completions"),
|
|
||||||
);
|
|
||||||
setCompletions([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchCompletions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatCompletionsTable
|
|
||||||
data={completions}
|
|
||||||
isLoading={isLoading}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import type { ResponseListResponse } from "llama-stack-client/resources/responses/responses";
|
|
||||||
import { OpenAIResponse } from "@/lib/types";
|
|
||||||
import { ResponsesTable } from "@/components/responses/responses-table";
|
import { ResponsesTable } from "@/components/responses/responses-table";
|
||||||
import { client } from "@/lib/client";
|
|
||||||
|
|
||||||
export default function ResponsesPage() {
|
export default function ResponsesPage() {
|
||||||
const [responses, setResponses] = useState<OpenAIResponse[]>([]);
|
return <ResponsesTable paginationOptions={{ limit: 20 }} />;
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
|
|
||||||
// Helper function to convert ResponseListResponse.Data to OpenAIResponse
|
|
||||||
const convertResponseListData = (
|
|
||||||
responseData: ResponseListResponse.Data,
|
|
||||||
): OpenAIResponse => {
|
|
||||||
return {
|
|
||||||
id: responseData.id,
|
|
||||||
created_at: responseData.created_at,
|
|
||||||
model: responseData.model,
|
|
||||||
object: responseData.object,
|
|
||||||
status: responseData.status,
|
|
||||||
output: responseData.output as OpenAIResponse["output"],
|
|
||||||
input: responseData.input as OpenAIResponse["input"],
|
|
||||||
error: responseData.error,
|
|
||||||
parallel_tool_calls: responseData.parallel_tool_calls,
|
|
||||||
previous_response_id: responseData.previous_response_id,
|
|
||||||
temperature: responseData.temperature,
|
|
||||||
top_p: responseData.top_p,
|
|
||||||
truncation: responseData.truncation,
|
|
||||||
user: responseData.user,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchResponses = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await client.responses.list();
|
|
||||||
const responseListData = response as ResponseListResponse;
|
|
||||||
|
|
||||||
const convertedResponses: OpenAIResponse[] = responseListData.data.map(
|
|
||||||
convertResponseListData,
|
|
||||||
);
|
|
||||||
|
|
||||||
setResponses(convertedResponses);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error fetching responses:", err);
|
|
||||||
setError(
|
|
||||||
err instanceof Error ? err : new Error("Failed to fetch responses"),
|
|
||||||
);
|
|
||||||
setResponses([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchResponses();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsesTable data={responses} isLoading={isLoading} error={error} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,29 @@ jest.mock("next/navigation", () => ({
|
||||||
jest.mock("@/lib/truncate-text");
|
jest.mock("@/lib/truncate-text");
|
||||||
jest.mock("@/lib/format-message-content");
|
jest.mock("@/lib/format-message-content");
|
||||||
|
|
||||||
|
// Mock the client
|
||||||
|
jest.mock("@/lib/client", () => ({
|
||||||
|
client: {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
list: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the usePagination hook
|
||||||
|
const mockLoadMore = jest.fn();
|
||||||
|
jest.mock("@/hooks/usePagination", () => ({
|
||||||
|
usePagination: jest.fn(() => ({
|
||||||
|
data: [],
|
||||||
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
// Import the mocked functions to set up default or specific implementations
|
// Import the mocked functions to set up default or specific implementations
|
||||||
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
||||||
import {
|
import {
|
||||||
|
@ -23,6 +46,12 @@ import {
|
||||||
extractDisplayableText as originalExtractDisplayableText,
|
extractDisplayableText as originalExtractDisplayableText,
|
||||||
} from "@/lib/format-message-content";
|
} from "@/lib/format-message-content";
|
||||||
|
|
||||||
|
// Import the mocked hook
|
||||||
|
import { usePagination } from "@/hooks/usePagination";
|
||||||
|
const mockedUsePagination = usePagination as jest.MockedFunction<
|
||||||
|
typeof usePagination
|
||||||
|
>;
|
||||||
|
|
||||||
// Cast to jest.Mock for typings
|
// Cast to jest.Mock for typings
|
||||||
const truncateText = originalTruncateText as jest.Mock;
|
const truncateText = originalTruncateText as jest.Mock;
|
||||||
const extractTextFromContentPart =
|
const extractTextFromContentPart =
|
||||||
|
@ -30,11 +59,7 @@ const extractTextFromContentPart =
|
||||||
const extractDisplayableText = originalExtractDisplayableText as jest.Mock;
|
const extractDisplayableText = originalExtractDisplayableText as jest.Mock;
|
||||||
|
|
||||||
describe("ChatCompletionsTable", () => {
|
describe("ChatCompletionsTable", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {};
|
||||||
data: [] as ChatCompletion[],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset all mocks before each test
|
// Reset all mocks before each test
|
||||||
|
@ -42,16 +67,27 @@ describe("ChatCompletionsTable", () => {
|
||||||
truncateText.mockClear();
|
truncateText.mockClear();
|
||||||
extractTextFromContentPart.mockClear();
|
extractTextFromContentPart.mockClear();
|
||||||
extractDisplayableText.mockClear();
|
extractDisplayableText.mockClear();
|
||||||
|
mockLoadMore.mockClear();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
// Default pass-through implementations
|
// Default pass-through implementations
|
||||||
truncateText.mockImplementation((text: string | undefined) => text);
|
truncateText.mockImplementation((text: string | undefined) => text);
|
||||||
extractTextFromContentPart.mockImplementation((content: unknown) =>
|
extractTextFromContentPart.mockImplementation((content: unknown) =>
|
||||||
typeof content === "string" ? content : "extracted text",
|
typeof content === "string" ? content : "extracted text",
|
||||||
);
|
);
|
||||||
extractDisplayableText.mockImplementation(
|
extractDisplayableText.mockImplementation((message: unknown) => {
|
||||||
(message: unknown) =>
|
const msg = message as { content?: string };
|
||||||
(message as { content?: string })?.content || "extracted output",
|
return msg?.content || "extracted output";
|
||||||
);
|
});
|
||||||
|
|
||||||
|
// Default hook return value
|
||||||
|
mockedUsePagination.mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders without crashing with default props", () => {
|
test("renders without crashing with default props", () => {
|
||||||
|
@ -60,41 +96,56 @@ describe("ChatCompletionsTable", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("click on a row navigates to the correct URL", () => {
|
test("click on a row navigates to the correct URL", () => {
|
||||||
const mockCompletion: ChatCompletion = {
|
const mockData: ChatCompletion[] = [
|
||||||
id: "comp_123",
|
{
|
||||||
object: "chat.completion",
|
id: "completion_123",
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: "llama-test-model",
|
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
message: { role: "assistant", content: "Test response" },
|
||||||
message: { role: "assistant", content: "Test output" },
|
|
||||||
finish_reason: "stop",
|
finish_reason: "stop",
|
||||||
|
index: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
input_messages: [{ role: "user", content: "Test input" }],
|
object: "chat.completion",
|
||||||
};
|
created: 1234567890,
|
||||||
|
model: "test-model",
|
||||||
|
input_messages: [{ role: "user", content: "Test prompt" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Set up mocks to return expected values
|
// Configure the mock to return our test data
|
||||||
extractTextFromContentPart.mockReturnValue("Test input");
|
mockedUsePagination.mockReturnValue({
|
||||||
extractDisplayableText.mockReturnValue("Test output");
|
data: mockData,
|
||||||
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
render(<ChatCompletionsTable {...defaultProps} data={[mockCompletion]} />);
|
render(<ChatCompletionsTable {...defaultProps} />);
|
||||||
|
|
||||||
const row = screen.getByText("Test input").closest("tr");
|
const row = screen.getByText("Test prompt").closest("tr");
|
||||||
if (row) {
|
if (row) {
|
||||||
fireEvent.click(row);
|
fireEvent.click(row);
|
||||||
expect(mockPush).toHaveBeenCalledWith("/logs/chat-completions/comp_123");
|
expect(mockPush).toHaveBeenCalledWith(
|
||||||
|
"/logs/chat-completions/completion_123",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Row with "Test input" not found for router mock test.');
|
throw new Error('Row with "Test prompt" not found for router mock test.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Loading State", () => {
|
describe("Loading State", () => {
|
||||||
test("renders skeleton UI when isLoading is true", () => {
|
test("renders skeleton UI when isLoading is true", () => {
|
||||||
const { container } = render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ChatCompletionsTable {...defaultProps} isLoading={true} />,
|
data: [],
|
||||||
);
|
status: "loading",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<ChatCompletionsTable {...defaultProps} />);
|
||||||
|
|
||||||
// Check for skeleton in the table caption
|
// Check for skeleton in the table caption
|
||||||
const tableCaption = container.querySelector("caption");
|
const tableCaption = container.querySelector("caption");
|
||||||
|
@ -121,40 +172,48 @@ describe("ChatCompletionsTable", () => {
|
||||||
describe("Error State", () => {
|
describe("Error State", () => {
|
||||||
test("renders error message when error prop is provided", () => {
|
test("renders error message when error prop is provided", () => {
|
||||||
const errorMessage = "Network Error";
|
const errorMessage = "Network Error";
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ChatCompletionsTable
|
data: [],
|
||||||
{...defaultProps}
|
status: "error",
|
||||||
error={{ name: "Error", message: errorMessage }}
|
hasMore: false,
|
||||||
/>,
|
error: { name: "Error", message: errorMessage } as Error,
|
||||||
);
|
loadMore: mockLoadMore,
|
||||||
expect(
|
|
||||||
screen.getByText(`Error fetching data: ${errorMessage}`),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders default error message when error.message is not available", () => {
|
render(<ChatCompletionsTable {...defaultProps} />);
|
||||||
render(
|
|
||||||
<ChatCompletionsTable
|
|
||||||
{...defaultProps}
|
|
||||||
error={{ name: "Error", message: "" }}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
screen.getByText("Unable to load chat completions"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders default error message when error prop is an object without message", () => {
|
test.each([{ name: "Error", message: "" }, {}])(
|
||||||
render(<ChatCompletionsTable {...defaultProps} error={{} as Error} />);
|
"renders default error message when error has no message",
|
||||||
expect(
|
(errorObject) => {
|
||||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
mockedUsePagination.mockReturnValue({
|
||||||
).toBeInTheDocument();
|
data: [],
|
||||||
|
status: "error",
|
||||||
|
hasMore: false,
|
||||||
|
error: errorObject as Error,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
render(<ChatCompletionsTable {...defaultProps} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Unable to load chat completions"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"An unexpected error occurred while loading the data.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Empty State", () => {
|
describe("Empty State", () => {
|
||||||
test('renders "No chat completions found." and no table when data array is empty', () => {
|
test('renders "No chat completions found." and no table when data array is empty', () => {
|
||||||
render(<ChatCompletionsTable data={[]} isLoading={false} error={null} />);
|
render(<ChatCompletionsTable {...defaultProps} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("No chat completions found."),
|
screen.getByText("No chat completions found."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
@ -167,7 +226,7 @@ describe("ChatCompletionsTable", () => {
|
||||||
|
|
||||||
describe("Data Rendering", () => {
|
describe("Data Rendering", () => {
|
||||||
test("renders table caption, headers, and completion data correctly", () => {
|
test("renders table caption, headers, and completion data correctly", () => {
|
||||||
const mockCompletions = [
|
const mockCompletions: ChatCompletion[] = [
|
||||||
{
|
{
|
||||||
id: "comp_1",
|
id: "comp_1",
|
||||||
object: "chat.completion",
|
object: "chat.completion",
|
||||||
|
@ -211,13 +270,15 @@ describe("ChatCompletionsTable", () => {
|
||||||
return "extracted output";
|
return "extracted output";
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ChatCompletionsTable
|
data: mockCompletions,
|
||||||
data={mockCompletions}
|
status: "idle",
|
||||||
isLoading={false}
|
hasMore: false,
|
||||||
error={null}
|
error: null,
|
||||||
/>,
|
loadMore: mockLoadMore,
|
||||||
);
|
});
|
||||||
|
|
||||||
|
render(<ChatCompletionsTable {...defaultProps} />);
|
||||||
|
|
||||||
// Table caption
|
// Table caption
|
||||||
expect(
|
expect(
|
||||||
|
@ -268,7 +329,7 @@ describe("ChatCompletionsTable", () => {
|
||||||
extractTextFromContentPart.mockReturnValue(longInput);
|
extractTextFromContentPart.mockReturnValue(longInput);
|
||||||
extractDisplayableText.mockReturnValue(longOutput);
|
extractDisplayableText.mockReturnValue(longOutput);
|
||||||
|
|
||||||
const mockCompletions = [
|
const mockCompletions: ChatCompletion[] = [
|
||||||
{
|
{
|
||||||
id: "comp_trunc",
|
id: "comp_trunc",
|
||||||
object: "chat.completion",
|
object: "chat.completion",
|
||||||
|
@ -285,26 +346,35 @@ describe("ChatCompletionsTable", () => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ChatCompletionsTable
|
data: mockCompletions,
|
||||||
data={mockCompletions}
|
status: "idle",
|
||||||
isLoading={false}
|
hasMore: false,
|
||||||
error={null}
|
error: null,
|
||||||
/>,
|
loadMore: mockLoadMore,
|
||||||
);
|
});
|
||||||
|
|
||||||
|
render(<ChatCompletionsTable {...defaultProps} />);
|
||||||
|
|
||||||
// The truncated text should be present for both input and output
|
// The truncated text should be present for both input and output
|
||||||
const truncatedTexts = screen.getAllByText(
|
const truncatedTexts = screen.getAllByText(
|
||||||
longInput.slice(0, 10) + "...",
|
longInput.slice(0, 10) + "...",
|
||||||
);
|
);
|
||||||
expect(truncatedTexts.length).toBe(2); // one for input, one for output
|
expect(truncatedTexts.length).toBe(2); // one for input, one for output
|
||||||
truncatedTexts.forEach((textElement) =>
|
|
||||||
expect(textElement).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses content extraction functions correctly", () => {
|
test("uses content extraction functions correctly", () => {
|
||||||
const mockCompletion = {
|
const complexMessage = [
|
||||||
|
{ type: "text", text: "Extracted input" },
|
||||||
|
{ type: "image", url: "http://example.com/image.png" },
|
||||||
|
];
|
||||||
|
const assistantMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Extracted output from assistant",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCompletions: ChatCompletion[] = [
|
||||||
|
{
|
||||||
id: "comp_extract",
|
id: "comp_extract",
|
||||||
object: "chat.completion",
|
object: "chat.completion",
|
||||||
created: 1710003000,
|
created: 1710003000,
|
||||||
|
@ -312,36 +382,36 @@ describe("ChatCompletionsTable", () => {
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
message: { role: "assistant", content: "Extracted output" },
|
message: assistantMessage,
|
||||||
finish_reason: "stop",
|
finish_reason: "stop",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
input_messages: [{ role: "user", content: "Extracted input" }],
|
input_messages: [{ role: "user", content: complexMessage }],
|
||||||
};
|
},
|
||||||
|
];
|
||||||
|
|
||||||
extractTextFromContentPart.mockReturnValue("Extracted input");
|
extractTextFromContentPart.mockReturnValue("Extracted input");
|
||||||
extractDisplayableText.mockReturnValue("Extracted output");
|
extractDisplayableText.mockReturnValue("Extracted output from assistant");
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ChatCompletionsTable
|
data: mockCompletions,
|
||||||
data={[mockCompletion]}
|
status: "idle",
|
||||||
isLoading={false}
|
hasMore: false,
|
||||||
error={null}
|
error: null,
|
||||||
/>,
|
loadMore: mockLoadMore,
|
||||||
);
|
|
||||||
|
|
||||||
// Verify the extraction functions were called
|
|
||||||
expect(extractTextFromContentPart).toHaveBeenCalledWith(
|
|
||||||
"Extracted input",
|
|
||||||
);
|
|
||||||
expect(extractDisplayableText).toHaveBeenCalledWith({
|
|
||||||
role: "assistant",
|
|
||||||
content: "Extracted output",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify the extracted content is displayed
|
render(<ChatCompletionsTable {...defaultProps} />);
|
||||||
|
|
||||||
|
// Verify the extraction functions were called
|
||||||
|
expect(extractTextFromContentPart).toHaveBeenCalledWith(complexMessage);
|
||||||
|
expect(extractDisplayableText).toHaveBeenCalledWith(assistantMessage);
|
||||||
|
|
||||||
|
// Verify the extracted text appears in the table
|
||||||
expect(screen.getByText("Extracted input")).toBeInTheDocument();
|
expect(screen.getByText("Extracted input")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Extracted output")).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText("Extracted output from assistant"),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,16 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChatCompletion } from "@/lib/types";
|
import {
|
||||||
|
ChatCompletion,
|
||||||
|
UsePaginationOptions,
|
||||||
|
ListChatCompletionsResponse,
|
||||||
|
} from "@/lib/types";
|
||||||
import { LogsTable, LogTableRow } from "@/components/logs/logs-table";
|
import { LogsTable, LogTableRow } from "@/components/logs/logs-table";
|
||||||
import {
|
import {
|
||||||
extractTextFromContentPart,
|
extractTextFromContentPart,
|
||||||
extractDisplayableText,
|
extractDisplayableText,
|
||||||
} from "@/lib/format-message-content";
|
} from "@/lib/format-message-content";
|
||||||
|
import { usePagination } from "@/hooks/usePagination";
|
||||||
|
import { client } from "@/lib/client";
|
||||||
|
|
||||||
interface ChatCompletionsTableProps {
|
interface ChatCompletionsTableProps {
|
||||||
data: ChatCompletion[];
|
/** Optional pagination configuration */
|
||||||
isLoading: boolean;
|
paginationOptions?: UsePaginationOptions;
|
||||||
error: Error | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChatCompletionToRow(completion: ChatCompletion): LogTableRow {
|
function formatChatCompletionToRow(completion: ChatCompletion): LogTableRow {
|
||||||
|
@ -25,17 +30,39 @@ function formatChatCompletionToRow(completion: ChatCompletion): LogTableRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatCompletionsTable({
|
export function ChatCompletionsTable({
|
||||||
data,
|
paginationOptions,
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
}: ChatCompletionsTableProps) {
|
}: ChatCompletionsTableProps) {
|
||||||
|
const fetchFunction = async (params: {
|
||||||
|
after?: string;
|
||||||
|
limit: number;
|
||||||
|
model?: string;
|
||||||
|
order?: string;
|
||||||
|
}) => {
|
||||||
|
const response = await client.chat.completions.list({
|
||||||
|
after: params.after,
|
||||||
|
limit: params.limit,
|
||||||
|
...(params.model && { model: params.model }),
|
||||||
|
...(params.order && { order: params.order }),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return response as ListChatCompletionsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, status, hasMore, error, loadMore } = usePagination({
|
||||||
|
...paginationOptions,
|
||||||
|
fetchFunction,
|
||||||
|
errorMessagePrefix: "chat completions",
|
||||||
|
});
|
||||||
|
|
||||||
const formattedData = data.map(formatChatCompletionToRow);
|
const formattedData = data.map(formatChatCompletionToRow);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LogsTable
|
<LogsTable
|
||||||
data={formattedData}
|
data={formattedData}
|
||||||
isLoading={isLoading}
|
status={status}
|
||||||
|
hasMore={hasMore}
|
||||||
error={error}
|
error={error}
|
||||||
|
onLoadMore={loadMore}
|
||||||
caption="A list of your recent chat completions."
|
caption="A list of your recent chat completions."
|
||||||
emptyMessage="No chat completions found."
|
emptyMessage="No chat completions found."
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -37,13 +37,11 @@ export default function LogsLayout({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4 h-[calc(100vh-64px)] flex flex-col">
|
||||||
<>
|
|
||||||
{segments.length > 0 && (
|
{segments.length > 0 && (
|
||||||
<PageBreadcrumb segments={segments} className="mb-4" />
|
<PageBreadcrumb segments={segments} className="mb-4" />
|
||||||
)}
|
)}
|
||||||
{children}
|
<div className="flex-1 min-h-0 flex flex-col">{children}</div>
|
||||||
</>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
142
llama_stack/ui/components/logs/logs-table-scroll.test.tsx
Normal file
142
llama_stack/ui/components/logs/logs-table-scroll.test.tsx
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
import React from "react";
|
||||||
|
import { render, waitFor } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { LogsTable, LogTableRow } from "./logs-table";
|
||||||
|
import { PaginationStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
jest.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the useInfiniteScroll hook
|
||||||
|
jest.mock("@/hooks/useInfiniteScroll", () => ({
|
||||||
|
useInfiniteScroll: jest.fn((onLoadMore, options) => {
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Simulate the observer behavior
|
||||||
|
if (options?.enabled && onLoadMore) {
|
||||||
|
// Trigger load after a delay to simulate intersection
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
onLoadMore();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [options?.enabled, onLoadMore]);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// IntersectionObserver mock is already in jest.setup.ts
|
||||||
|
|
||||||
|
describe("LogsTable Viewport Loading", () => {
|
||||||
|
const mockData: LogTableRow[] = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
id: `row_${i}`,
|
||||||
|
input: `Input ${i}`,
|
||||||
|
output: `Output ${i}`,
|
||||||
|
model: "test-model",
|
||||||
|
createdTime: new Date().toISOString(),
|
||||||
|
detailPath: `/logs/test/${i}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
data: mockData,
|
||||||
|
status: "idle" as PaginationStatus,
|
||||||
|
hasMore: true,
|
||||||
|
error: null,
|
||||||
|
caption: "Test table",
|
||||||
|
emptyMessage: "No data",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should trigger loadMore when sentinel is visible", async () => {
|
||||||
|
const mockLoadMore = jest.fn();
|
||||||
|
|
||||||
|
render(<LogsTable {...defaultProps} onLoadMore={mockLoadMore} />);
|
||||||
|
|
||||||
|
// Wait for the intersection observer to trigger
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(mockLoadMore).toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
{ timeout: 300 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockLoadMore).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not trigger loadMore when already loading", async () => {
|
||||||
|
const mockLoadMore = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LogsTable
|
||||||
|
{...defaultProps}
|
||||||
|
status="loading-more"
|
||||||
|
onLoadMore={mockLoadMore}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for possible triggers
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
expect(mockLoadMore).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not trigger loadMore when status is loading", async () => {
|
||||||
|
const mockLoadMore = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LogsTable
|
||||||
|
{...defaultProps}
|
||||||
|
status="loading"
|
||||||
|
onLoadMore={mockLoadMore}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for possible triggers
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
expect(mockLoadMore).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not trigger loadMore when hasMore is false", async () => {
|
||||||
|
const mockLoadMore = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LogsTable {...defaultProps} hasMore={false} onLoadMore={mockLoadMore} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for possible triggers
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
expect(mockLoadMore).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sentinel element should not be rendered when loading", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<LogsTable {...defaultProps} status="loading-more" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that no sentinel row with height: 1 exists
|
||||||
|
const sentinelRow = container.querySelector('tr[style*="height: 1"]');
|
||||||
|
expect(sentinelRow).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sentinel element should be rendered when not loading and hasMore", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<LogsTable {...defaultProps} hasMore={true} status="idle" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that sentinel row exists
|
||||||
|
const sentinelRow = container.querySelector('tr[style*="height: 1"]');
|
||||||
|
expect(sentinelRow).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { LogsTable, LogTableRow } from "./logs-table";
|
import { LogsTable, LogTableRow } from "./logs-table";
|
||||||
|
import { PaginationStatus } from "@/lib/types";
|
||||||
|
|
||||||
// Mock next/navigation
|
// Mock next/navigation
|
||||||
const mockPush = jest.fn();
|
const mockPush = jest.fn();
|
||||||
|
@ -23,7 +24,7 @@ const truncateText = originalTruncateText as jest.Mock;
|
||||||
describe("LogsTable", () => {
|
describe("LogsTable", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
data: [] as LogTableRow[],
|
data: [] as LogTableRow[],
|
||||||
isLoading: false,
|
status: "idle" as PaginationStatus,
|
||||||
error: null,
|
error: null,
|
||||||
caption: "Test table caption",
|
caption: "Test table caption",
|
||||||
emptyMessage: "No data found",
|
emptyMessage: "No data found",
|
||||||
|
@ -69,7 +70,7 @@ describe("LogsTable", () => {
|
||||||
describe("Loading State", () => {
|
describe("Loading State", () => {
|
||||||
test("renders skeleton UI when isLoading is true", () => {
|
test("renders skeleton UI when isLoading is true", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<LogsTable {...defaultProps} isLoading={true} />,
|
<LogsTable {...defaultProps} status="loading" />,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check for skeleton in the table caption
|
// Check for skeleton in the table caption
|
||||||
|
@ -101,7 +102,7 @@ describe("LogsTable", () => {
|
||||||
|
|
||||||
test("renders correct number of skeleton rows", () => {
|
test("renders correct number of skeleton rows", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<LogsTable {...defaultProps} isLoading={true} />,
|
<LogsTable {...defaultProps} status="loading" />,
|
||||||
);
|
);
|
||||||
|
|
||||||
const skeletonRows = container.querySelectorAll("tbody tr");
|
const skeletonRows = container.querySelectorAll("tbody tr");
|
||||||
|
@ -115,27 +116,45 @@ describe("LogsTable", () => {
|
||||||
render(
|
render(
|
||||||
<LogsTable
|
<LogsTable
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
error={{ name: "Error", message: errorMessage }}
|
status="error"
|
||||||
|
error={{ name: "Error", message: errorMessage } as Error}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(`Error fetching data: ${errorMessage}`),
|
screen.getByText("Unable to load chat completions"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders default error message when error.message is not available", () => {
|
test("renders default error message when error.message is not available", () => {
|
||||||
render(
|
render(
|
||||||
<LogsTable {...defaultProps} error={{ name: "Error", message: "" }} />,
|
<LogsTable
|
||||||
|
{...defaultProps}
|
||||||
|
status="error"
|
||||||
|
error={{ name: "Error", message: "" } as Error}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
screen.getByText("Unable to load chat completions"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"An unexpected error occurred while loading the data.",
|
||||||
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders default error message when error prop is an object without message", () => {
|
test("renders default error message when error prop is an object without message", () => {
|
||||||
render(<LogsTable {...defaultProps} error={{} as Error} />);
|
render(
|
||||||
|
<LogsTable {...defaultProps} status="error" error={{} as Error} />,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
screen.getByText("Unable to load chat completions"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"An unexpected error occurred while loading the data.",
|
||||||
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -143,7 +162,8 @@ describe("LogsTable", () => {
|
||||||
render(
|
render(
|
||||||
<LogsTable
|
<LogsTable
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
error={{ name: "Error", message: "Test error" }}
|
status="error"
|
||||||
|
error={{ name: "Error", message: "Test error" } as Error}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const table = screen.queryByRole("table");
|
const table = screen.queryByRole("table");
|
||||||
|
@ -337,14 +357,19 @@ describe("LogsTable", () => {
|
||||||
|
|
||||||
render(<LogsTable {...defaultProps} data={mockData} />);
|
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||||
|
|
||||||
const table = screen.getByRole("table");
|
const tables = screen.getAllByRole("table");
|
||||||
expect(table).toBeInTheDocument();
|
expect(tables).toHaveLength(2); // Fixed header table + body table
|
||||||
|
|
||||||
const columnHeaders = screen.getAllByRole("columnheader");
|
const columnHeaders = screen.getAllByRole("columnheader");
|
||||||
expect(columnHeaders).toHaveLength(4);
|
expect(columnHeaders).toHaveLength(4);
|
||||||
|
|
||||||
const rows = screen.getAllByRole("row");
|
const rows = screen.getAllByRole("row");
|
||||||
expect(rows).toHaveLength(2); // 1 header row + 1 data row
|
expect(rows).toHaveLength(3); // 1 header row + 1 data row + 1 "no more items" row
|
||||||
|
|
||||||
|
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Created")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useRef } from "react";
|
||||||
import { truncateText } from "@/lib/truncate-text";
|
import { truncateText } from "@/lib/truncate-text";
|
||||||
|
import { PaginationStatus } from "@/lib/types";
|
||||||
|
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
@ -24,65 +27,107 @@ export interface LogTableRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LogsTableProps {
|
interface LogsTableProps {
|
||||||
|
/** Array of log table row data to display */
|
||||||
data: LogTableRow[];
|
data: LogTableRow[];
|
||||||
isLoading: boolean;
|
/** Current loading/error status */
|
||||||
|
status: PaginationStatus;
|
||||||
|
/** Whether more data is available to load */
|
||||||
|
hasMore?: boolean;
|
||||||
|
/** Error state, null if no error */
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
|
/** Table caption for accessibility */
|
||||||
caption: string;
|
caption: string;
|
||||||
|
/** Message to show when no data is available */
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
|
/** Callback function to load more data */
|
||||||
|
onLoadMore?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogsTable({
|
export function LogsTable({
|
||||||
data,
|
data,
|
||||||
isLoading,
|
status,
|
||||||
|
hasMore = false,
|
||||||
error,
|
error,
|
||||||
caption,
|
caption,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
onLoadMore,
|
||||||
}: LogsTableProps) {
|
}: LogsTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const tableHeader = (
|
// Use Intersection Observer for infinite scroll
|
||||||
|
const sentinelRef = useInfiniteScroll(onLoadMore, {
|
||||||
|
enabled: hasMore && status === "idle",
|
||||||
|
rootMargin: "100px",
|
||||||
|
threshold: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fixed header component
|
||||||
|
const FixedHeader = () => (
|
||||||
|
<div className="bg-background border-b border-border">
|
||||||
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Input</TableHead>
|
<TableHead className="w-1/4">Input</TableHead>
|
||||||
<TableHead>Output</TableHead>
|
<TableHead className="w-1/4">Output</TableHead>
|
||||||
<TableHead>Model</TableHead>
|
<TableHead className="w-1/4">Model</TableHead>
|
||||||
<TableHead className="text-right">Created</TableHead>
|
<TableHead className="w-1/4 text-right">Created</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<FixedHeader />
|
||||||
|
<div ref={tableContainerRef} className="overflow-auto flex-1 min-h-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableCaption>
|
<TableCaption>
|
||||||
<Skeleton className="h-4 w-[250px] mx-auto" />
|
<Skeleton className="h-4 w-[250px] mx-auto" />
|
||||||
</TableCaption>
|
</TableCaption>
|
||||||
{tableHeader}
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<TableRow key={`skeleton-${i}`}>
|
<TableRow key={`skeleton-${i}`}>
|
||||||
<TableCell>
|
<TableCell className="w-1/4">
|
||||||
<Skeleton className="h-4 w-full" />
|
<Skeleton className="h-4 w-full" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="w-1/4">
|
||||||
<Skeleton className="h-4 w-full" />
|
<Skeleton className="h-4 w-full" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="w-1/4">
|
||||||
<Skeleton className="h-4 w-3/4" />
|
<Skeleton className="h-4 w-3/4" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="w-1/4 text-right">
|
||||||
<Skeleton className="h-4 w-1/2 ml-auto" />
|
<Skeleton className="h-4 w-1/2 ml-auto" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<p>Error fetching data: {error.message || "An unknown error occurred"}</p>
|
<div className="flex flex-col items-center justify-center p-8 space-y-4">
|
||||||
|
<div className="text-destructive font-medium">
|
||||||
|
Unable to load chat completions
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground text-center max-w-md">
|
||||||
|
{error?.message ||
|
||||||
|
"An unexpected error occurred while loading the data."}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,9 +136,11 @@ export function LogsTable({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<FixedHeader />
|
||||||
|
<div ref={tableContainerRef} className="overflow-auto flex-1 min-h-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableCaption>{caption}</TableCaption>
|
<TableCaption className="sr-only">{caption}</TableCaption>
|
||||||
{tableHeader}
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map((row) => (
|
{data.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
|
@ -101,13 +148,48 @@ export function LogsTable({
|
||||||
onClick={() => router.push(row.detailPath)}
|
onClick={() => router.push(row.detailPath)}
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<TableCell>{truncateText(row.input)}</TableCell>
|
<TableCell className="w-1/4">
|
||||||
<TableCell>{truncateText(row.output)}</TableCell>
|
{truncateText(row.input)}
|
||||||
<TableCell>{row.model}</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">{row.createdTime}</TableCell>
|
<TableCell className="w-1/4">
|
||||||
|
{truncateText(row.output)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-1/4">{row.model}</TableCell>
|
||||||
|
<TableCell className="w-1/4 text-right">
|
||||||
|
{row.createdTime}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
{/* Sentinel element for infinite scroll */}
|
||||||
|
{hasMore && status === "idle" && (
|
||||||
|
<TableRow ref={sentinelRef} style={{ height: 1 }}>
|
||||||
|
<TableCell colSpan={4} style={{ padding: 0, border: 0 }} />
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{status === "loading-more" && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-4">
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<Skeleton className="h-4 w-4 rounded-full" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Loading more...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!hasMore && data.length > 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
No more items to load
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,26 +15,60 @@ jest.mock("next/navigation", () => ({
|
||||||
// Mock helper functions
|
// Mock helper functions
|
||||||
jest.mock("@/lib/truncate-text");
|
jest.mock("@/lib/truncate-text");
|
||||||
|
|
||||||
|
// Mock the client
|
||||||
|
jest.mock("@/lib/client", () => ({
|
||||||
|
client: {
|
||||||
|
responses: {
|
||||||
|
list: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the usePagination hook
|
||||||
|
const mockLoadMore = jest.fn();
|
||||||
|
jest.mock("@/hooks/usePagination", () => ({
|
||||||
|
usePagination: jest.fn(() => ({
|
||||||
|
data: [],
|
||||||
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
// Import the mocked functions
|
// Import the mocked functions
|
||||||
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
||||||
|
|
||||||
|
// Import the mocked hook
|
||||||
|
import { usePagination } from "@/hooks/usePagination";
|
||||||
|
const mockedUsePagination = usePagination as jest.MockedFunction<
|
||||||
|
typeof usePagination
|
||||||
|
>;
|
||||||
|
|
||||||
// Cast to jest.Mock for typings
|
// Cast to jest.Mock for typings
|
||||||
const truncateText = originalTruncateText as jest.Mock;
|
const truncateText = originalTruncateText as jest.Mock;
|
||||||
|
|
||||||
describe("ResponsesTable", () => {
|
describe("ResponsesTable", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {};
|
||||||
data: [] as OpenAIResponse[],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset all mocks before each test
|
// Reset all mocks before each test
|
||||||
mockPush.mockClear();
|
mockPush.mockClear();
|
||||||
truncateText.mockClear();
|
truncateText.mockClear();
|
||||||
|
mockLoadMore.mockClear();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
// Default pass-through implementation
|
// Default pass-through implementation
|
||||||
truncateText.mockImplementation((text: string | undefined) => text);
|
truncateText.mockImplementation((text: string | undefined) => text);
|
||||||
|
|
||||||
|
// Default hook return value
|
||||||
|
mockedUsePagination.mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders without crashing with default props", () => {
|
test("renders without crashing with default props", () => {
|
||||||
|
@ -65,7 +99,16 @@ describe("ResponsesTable", () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<ResponsesTable {...defaultProps} data={[mockResponse]} />);
|
// Configure the mock to return our test data
|
||||||
|
mockedUsePagination.mockReturnValue({
|
||||||
|
data: [mockResponse],
|
||||||
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
|
|
||||||
const row = screen.getByText("Test input").closest("tr");
|
const row = screen.getByText("Test input").closest("tr");
|
||||||
if (row) {
|
if (row) {
|
||||||
|
@ -77,10 +120,16 @@ describe("ResponsesTable", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Loading State", () => {
|
describe("Loading State", () => {
|
||||||
test("renders skeleton UI when isLoading is true", () => {
|
test("renders skeleton UI when status is loading", () => {
|
||||||
const { container } = render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable {...defaultProps} isLoading={true} />,
|
data: [],
|
||||||
);
|
status: "loading",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<ResponsesTable {...defaultProps} />);
|
||||||
|
|
||||||
// Check for skeleton in the table caption
|
// Check for skeleton in the table caption
|
||||||
const tableCaption = container.querySelector("caption");
|
const tableCaption = container.querySelector("caption");
|
||||||
|
@ -105,42 +154,50 @@ describe("ResponsesTable", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Error State", () => {
|
describe("Error State", () => {
|
||||||
test("renders error message when error prop is provided", () => {
|
test("renders error message when error is provided", () => {
|
||||||
const errorMessage = "Network Error";
|
const errorMessage = "Network Error";
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable
|
data: [],
|
||||||
{...defaultProps}
|
status: "error",
|
||||||
error={{ name: "Error", message: errorMessage }}
|
hasMore: false,
|
||||||
/>,
|
error: { name: "Error", message: errorMessage } as Error,
|
||||||
);
|
loadMore: mockLoadMore,
|
||||||
expect(
|
|
||||||
screen.getByText(`Error fetching data: ${errorMessage}`),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders default error message when error.message is not available", () => {
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
render(
|
|
||||||
<ResponsesTable
|
|
||||||
{...defaultProps}
|
|
||||||
error={{ name: "Error", message: "" }}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
screen.getByText("Unable to load chat completions"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders default error message when error prop is an object without message", () => {
|
test.each([{ name: "Error", message: "" }, {}])(
|
||||||
render(<ResponsesTable {...defaultProps} error={{} as Error} />);
|
"renders default error message when error has no message",
|
||||||
expect(
|
(errorObject) => {
|
||||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
mockedUsePagination.mockReturnValue({
|
||||||
).toBeInTheDocument();
|
data: [],
|
||||||
|
status: "error",
|
||||||
|
hasMore: false,
|
||||||
|
error: errorObject as Error,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Unable to load chat completions"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"An unexpected error occurred while loading the data.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Empty State", () => {
|
describe("Empty State", () => {
|
||||||
test('renders "No responses found." and no table when data array is empty', () => {
|
test('renders "No responses found." and no table when data array is empty', () => {
|
||||||
render(<ResponsesTable data={[]} isLoading={false} error={null} />);
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
expect(screen.getByText("No responses found.")).toBeInTheDocument();
|
expect(screen.getByText("No responses found.")).toBeInTheDocument();
|
||||||
|
|
||||||
// Ensure that the table structure is NOT rendered in the empty state
|
// Ensure that the table structure is NOT rendered in the empty state
|
||||||
|
@ -151,7 +208,7 @@ describe("ResponsesTable", () => {
|
||||||
|
|
||||||
describe("Data Rendering", () => {
|
describe("Data Rendering", () => {
|
||||||
test("renders table caption, headers, and response data correctly", () => {
|
test("renders table caption, headers, and response data correctly", () => {
|
||||||
const mockResponses = [
|
const mockResponses: OpenAIResponse[] = [
|
||||||
{
|
{
|
||||||
id: "resp_1",
|
id: "resp_1",
|
||||||
object: "response" as const,
|
object: "response" as const,
|
||||||
|
@ -196,9 +253,15 @@ describe("ResponsesTable", () => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={mockResponses} isLoading={false} error={null} />,
|
data: mockResponses,
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
|
|
||||||
// Table caption
|
// Table caption
|
||||||
expect(
|
expect(
|
||||||
|
@ -246,9 +309,15 @@ describe("ResponsesTable", () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
expect(screen.getByText("Simple string input")).toBeInTheDocument();
|
expect(screen.getByText("Simple string input")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -272,9 +341,15 @@ describe("ResponsesTable", () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
expect(screen.getByText("Array input text")).toBeInTheDocument();
|
expect(screen.getByText("Array input text")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -294,9 +369,15 @@ describe("ResponsesTable", () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { container } = render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<ResponsesTable {...defaultProps} />);
|
||||||
|
|
||||||
// Find the input cell (first cell in the data row) and verify it's empty
|
// Find the input cell (first cell in the data row) and verify it's empty
|
||||||
const inputCell = container.querySelector("tbody tr td:first-child");
|
const inputCell = container.querySelector("tbody tr td:first-child");
|
||||||
|
@ -323,9 +404,15 @@ describe("ResponsesTable", () => {
|
||||||
input: [{ type: "message", content: "input" }],
|
input: [{ type: "message", content: "input" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
expect(screen.getByText("Simple string output")).toBeInTheDocument();
|
expect(screen.getByText("Simple string output")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -349,9 +436,15 @@ describe("ResponsesTable", () => {
|
||||||
input: [{ type: "message", content: "input" }],
|
input: [{ type: "message", content: "input" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
expect(screen.getByText("Array output text")).toBeInTheDocument();
|
expect(screen.getByText("Array output text")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -374,9 +467,15 @@ describe("ResponsesTable", () => {
|
||||||
input: [{ type: "message", content: "input" }],
|
input: [{ type: "message", content: "input" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText('search_function({"query": "test"})'),
|
screen.getByText('search_function({"query": "test"})'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
@ -400,9 +499,15 @@ describe("ResponsesTable", () => {
|
||||||
input: [{ type: "message", content: "input" }],
|
input: [{ type: "message", content: "input" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
expect(screen.getByText("simple_function({})")).toBeInTheDocument();
|
expect(screen.getByText("simple_function({})")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -423,9 +528,15 @@ describe("ResponsesTable", () => {
|
||||||
input: [{ type: "message", content: "input" }],
|
input: [{ type: "message", content: "input" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("web_search_call(status: completed)"),
|
screen.getByText("web_search_call(status: completed)"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
@ -449,9 +560,15 @@ describe("ResponsesTable", () => {
|
||||||
input: [{ type: "message", content: "input" }],
|
input: [{ type: "message", content: "input" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
// Should contain the JSON stringified version
|
// Should contain the JSON stringified version
|
||||||
expect(screen.getByText(/unknown_call/)).toBeInTheDocument();
|
expect(screen.getByText(/unknown_call/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -472,9 +589,15 @@ describe("ResponsesTable", () => {
|
||||||
input: [{ type: "message", content: "input" }],
|
input: [{ type: "message", content: "input" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
// Should contain the JSON stringified version of the output array
|
// Should contain the JSON stringified version of the output array
|
||||||
expect(screen.getByText(/unknown_type/)).toBeInTheDocument();
|
expect(screen.getByText(/unknown_type/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -520,18 +643,21 @@ describe("ResponsesTable", () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
mockedUsePagination.mockReturnValue({
|
||||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
data: [mockResponse],
|
||||||
);
|
status: "idle",
|
||||||
|
hasMore: false,
|
||||||
|
error: null,
|
||||||
|
loadMore: mockLoadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResponsesTable {...defaultProps} />);
|
||||||
|
|
||||||
// The truncated text should be present for both input and output
|
// The truncated text should be present for both input and output
|
||||||
const truncatedTexts = screen.getAllByText(
|
const truncatedTexts = screen.getAllByText(
|
||||||
longInput.slice(0, 10) + "...",
|
longInput.slice(0, 10) + "...",
|
||||||
);
|
);
|
||||||
expect(truncatedTexts.length).toBe(2); // one for input, one for output
|
expect(truncatedTexts.length).toBe(2); // one for input, one for output
|
||||||
truncatedTexts.forEach((textElement) =>
|
|
||||||
expect(textElement).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,10 +2,13 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
OpenAIResponse,
|
OpenAIResponse,
|
||||||
ResponseInput,
|
|
||||||
ResponseInputMessageContent,
|
ResponseInputMessageContent,
|
||||||
|
UsePaginationOptions,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { LogsTable, LogTableRow } from "@/components/logs/logs-table";
|
import { LogsTable, LogTableRow } from "@/components/logs/logs-table";
|
||||||
|
import { usePagination } from "@/hooks/usePagination";
|
||||||
|
import { client } from "@/lib/client";
|
||||||
|
import type { ResponseListResponse } from "llama-stack-client/resources/responses/responses";
|
||||||
import {
|
import {
|
||||||
isMessageInput,
|
isMessageInput,
|
||||||
isMessageItem,
|
isMessageItem,
|
||||||
|
@ -17,11 +20,34 @@ import {
|
||||||
} from "./utils/item-types";
|
} from "./utils/item-types";
|
||||||
|
|
||||||
interface ResponsesTableProps {
|
interface ResponsesTableProps {
|
||||||
data: OpenAIResponse[];
|
/** Optional pagination configuration */
|
||||||
isLoading: boolean;
|
paginationOptions?: UsePaginationOptions;
|
||||||
error: Error | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to convert ResponseListResponse.Data to OpenAIResponse
|
||||||
|
*/
|
||||||
|
const convertResponseListData = (
|
||||||
|
responseData: ResponseListResponse.Data,
|
||||||
|
): OpenAIResponse => {
|
||||||
|
return {
|
||||||
|
id: responseData.id,
|
||||||
|
created_at: responseData.created_at,
|
||||||
|
model: responseData.model,
|
||||||
|
object: responseData.object,
|
||||||
|
status: responseData.status,
|
||||||
|
output: responseData.output as OpenAIResponse["output"],
|
||||||
|
input: responseData.input as OpenAIResponse["input"],
|
||||||
|
error: responseData.error,
|
||||||
|
parallel_tool_calls: responseData.parallel_tool_calls,
|
||||||
|
previous_response_id: responseData.previous_response_id,
|
||||||
|
temperature: responseData.temperature,
|
||||||
|
top_p: responseData.top_p,
|
||||||
|
truncation: responseData.truncation,
|
||||||
|
user: responseData.user,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
function getInputText(response: OpenAIResponse): string {
|
function getInputText(response: OpenAIResponse): string {
|
||||||
const firstInput = response.input.find(isMessageInput);
|
const firstInput = response.input.find(isMessageInput);
|
||||||
if (firstInput) {
|
if (firstInput) {
|
||||||
|
@ -98,18 +124,43 @@ function formatResponseToRow(response: OpenAIResponse): LogTableRow {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResponsesTable({
|
export function ResponsesTable({ paginationOptions }: ResponsesTableProps) {
|
||||||
data,
|
const fetchFunction = async (params: {
|
||||||
isLoading,
|
after?: string;
|
||||||
error,
|
limit: number;
|
||||||
}: ResponsesTableProps) {
|
model?: string;
|
||||||
|
order?: string;
|
||||||
|
}) => {
|
||||||
|
const response = await client.responses.list({
|
||||||
|
after: params.after,
|
||||||
|
limit: params.limit,
|
||||||
|
...(params.model && { model: params.model }),
|
||||||
|
...(params.order && { order: params.order }),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const listResponse = response as ResponseListResponse;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...listResponse,
|
||||||
|
data: listResponse.data.map(convertResponseListData),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, status, hasMore, error, loadMore } = usePagination({
|
||||||
|
...paginationOptions,
|
||||||
|
fetchFunction,
|
||||||
|
errorMessagePrefix: "responses",
|
||||||
|
});
|
||||||
|
|
||||||
const formattedData = data.map(formatResponseToRow);
|
const formattedData = data.map(formatResponseToRow);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LogsTable
|
<LogsTable
|
||||||
data={formattedData}
|
data={formattedData}
|
||||||
isLoading={isLoading}
|
status={status}
|
||||||
|
hasMore={hasMore}
|
||||||
error={error}
|
error={error}
|
||||||
|
onLoadMore={loadMore}
|
||||||
caption="A list of your recent responses."
|
caption="A list of your recent responses."
|
||||||
emptyMessage="No responses found."
|
emptyMessage="No responses found."
|
||||||
/>
|
/>
|
||||||
|
|
61
llama_stack/ui/e2e/logs-table-scroll.spec.ts
Normal file
61
llama_stack/ui/e2e/logs-table-scroll.spec.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("LogsTable Scroll and Progressive Loading", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Navigate to the chat completions page
|
||||||
|
await page.goto("/logs/chat-completions");
|
||||||
|
|
||||||
|
// Wait for initial data to load
|
||||||
|
await page.waitForSelector("table tbody tr", { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should progressively load more data to fill tall viewports", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Set a tall viewport (1400px height)
|
||||||
|
await page.setViewportSize({ width: 1200, height: 1400 });
|
||||||
|
|
||||||
|
// Wait for the table to be visible
|
||||||
|
await page.waitForSelector("table");
|
||||||
|
|
||||||
|
// Wait a bit for progressive loading to potentially trigger
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Count the number of rows loaded
|
||||||
|
const rowCount = await page.locator("table tbody tr").count();
|
||||||
|
|
||||||
|
// With a 1400px viewport, we should have more than the default 20 rows
|
||||||
|
// Assuming each row is ~50px, we should fit at least 25-30 rows
|
||||||
|
expect(rowCount).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should trigger infinite scroll when scrolling near bottom", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Set a medium viewport
|
||||||
|
await page.setViewportSize({ width: 1200, height: 800 });
|
||||||
|
|
||||||
|
// Wait for initial load
|
||||||
|
await page.waitForSelector("table tbody tr");
|
||||||
|
|
||||||
|
// Get initial row count
|
||||||
|
const initialRowCount = await page.locator("table tbody tr").count();
|
||||||
|
|
||||||
|
// Find the scrollable container
|
||||||
|
const scrollContainer = page.locator("div.overflow-auto").first();
|
||||||
|
|
||||||
|
// Scroll to near the bottom
|
||||||
|
await scrollContainer.evaluate((element) => {
|
||||||
|
element.scrollTop = element.scrollHeight - element.clientHeight - 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for loading indicator or new data
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Check if more rows were loaded
|
||||||
|
const newRowCount = await page.locator("table tbody tr").count();
|
||||||
|
|
||||||
|
// We should have more rows after scrolling
|
||||||
|
expect(newRowCount).toBeGreaterThan(initialRowCount);
|
||||||
|
});
|
||||||
|
});
|
55
llama_stack/ui/hooks/useInfiniteScroll.ts
Normal file
55
llama_stack/ui/hooks/useInfiniteScroll.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
interface UseInfiniteScrollOptions {
|
||||||
|
/** Whether the feature is enabled (e.g., hasMore data) */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Threshold for intersection (0-1, how much of sentinel must be visible) */
|
||||||
|
threshold?: number;
|
||||||
|
/** Margin around root to trigger earlier (e.g., "100px" to load 100px before visible) */
|
||||||
|
rootMargin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for infinite scroll using Intersection Observer
|
||||||
|
*
|
||||||
|
* @param onLoadMore - Callback to load more data
|
||||||
|
* @param options - Configuration options
|
||||||
|
* @returns ref to attach to sentinel element
|
||||||
|
*/
|
||||||
|
export function useInfiniteScroll(
|
||||||
|
onLoadMore: (() => void) | undefined,
|
||||||
|
options: UseInfiniteScrollOptions = {},
|
||||||
|
) {
|
||||||
|
const { enabled = true, threshold = 0.1, rootMargin = "100px" } = options;
|
||||||
|
const sentinelRef = useRef<HTMLTableRowElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onLoadMore || !enabled) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const [entry] = entries;
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
onLoadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold,
|
||||||
|
rootMargin,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
if (sentinel) {
|
||||||
|
observer.observe(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [onLoadMore, enabled, threshold, rootMargin]);
|
||||||
|
|
||||||
|
return sentinelRef;
|
||||||
|
}
|
132
llama_stack/ui/hooks/usePagination.ts
Normal file
132
llama_stack/ui/hooks/usePagination.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { PaginationStatus, UsePaginationOptions } from "@/lib/types";
|
||||||
|
|
||||||
|
interface PaginationState<T> {
|
||||||
|
data: T[];
|
||||||
|
status: PaginationStatus;
|
||||||
|
hasMore: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
lastId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
has_more: boolean;
|
||||||
|
last_id: string;
|
||||||
|
first_id: string;
|
||||||
|
object: "list";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationReturn<T> {
|
||||||
|
data: T[];
|
||||||
|
status: PaginationStatus;
|
||||||
|
hasMore: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
loadMore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsePaginationParams<T> extends UsePaginationOptions {
|
||||||
|
fetchFunction: (params: {
|
||||||
|
after?: string;
|
||||||
|
limit: number;
|
||||||
|
model?: string;
|
||||||
|
order?: string;
|
||||||
|
}) => Promise<PaginationResponse<T>>;
|
||||||
|
errorMessagePrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePagination<T>({
|
||||||
|
limit = 20,
|
||||||
|
model,
|
||||||
|
order = "desc",
|
||||||
|
fetchFunction,
|
||||||
|
errorMessagePrefix,
|
||||||
|
}: UsePaginationParams<T>): PaginationReturn<T> {
|
||||||
|
const [state, setState] = useState<PaginationState<T>>({
|
||||||
|
data: [],
|
||||||
|
status: "loading",
|
||||||
|
hasMore: true,
|
||||||
|
error: null,
|
||||||
|
lastId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use refs to avoid stale closures
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches data from the API with cursor-based pagination
|
||||||
|
*/
|
||||||
|
const fetchData = useCallback(
|
||||||
|
async (after?: string, targetRows?: number) => {
|
||||||
|
const isInitialLoad = !after;
|
||||||
|
const fetchLimit = targetRows || limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: isInitialLoad ? "loading" : "loading-more",
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await fetchFunction({
|
||||||
|
after: after || undefined,
|
||||||
|
limit: fetchLimit,
|
||||||
|
...(model && { model }),
|
||||||
|
...(order && { order }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
data: isInitialLoad
|
||||||
|
? response.data
|
||||||
|
: [...prev.data, ...response.data],
|
||||||
|
hasMore: response.has_more,
|
||||||
|
lastId: response.last_id || null,
|
||||||
|
status: "idle",
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = isInitialLoad
|
||||||
|
? `Failed to load ${errorMessagePrefix}. Please try refreshing the page.`
|
||||||
|
: `Failed to load more ${errorMessagePrefix}. Please try again.`;
|
||||||
|
|
||||||
|
const error =
|
||||||
|
err instanceof Error
|
||||||
|
? new Error(`${errorMessage} ${err.message}`)
|
||||||
|
: new Error(errorMessage);
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error,
|
||||||
|
status: "error",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[limit, model, order, fetchFunction, errorMessagePrefix],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads more data for infinite scroll
|
||||||
|
*/
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
const currentState = stateRef.current;
|
||||||
|
if (currentState.hasMore && currentState.status === "idle") {
|
||||||
|
fetchData(currentState.lastId || undefined);
|
||||||
|
}
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// Auto-load initial data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: state.data,
|
||||||
|
status: state.status,
|
||||||
|
hasMore: state.hasMore,
|
||||||
|
error: state.error,
|
||||||
|
loadMore,
|
||||||
|
};
|
||||||
|
}
|
|
@ -100,6 +100,7 @@ const config: Config = {
|
||||||
// However, for mocks, sometimes explicit mapping is needed.
|
// However, for mocks, sometimes explicit mapping is needed.
|
||||||
"^@/lib/(.*)$": "<rootDir>/lib/$1",
|
"^@/lib/(.*)$": "<rootDir>/lib/$1",
|
||||||
"^@/components/(.*)$": "<rootDir>/components/$1",
|
"^@/components/(.*)$": "<rootDir>/components/$1",
|
||||||
|
"^@/hooks/(.*)$": "<rootDir>/hooks/$1",
|
||||||
// Add other aliases here if needed
|
// Add other aliases here if needed
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -148,7 +149,7 @@ const config: Config = {
|
||||||
// setupFiles: [],
|
// setupFiles: [],
|
||||||
|
|
||||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||||
// setupFilesAfterEnv: [],
|
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
|
||||||
|
|
||||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||||
// slowTestThreshold: 5,
|
// slowTestThreshold: 5,
|
||||||
|
@ -172,9 +173,7 @@ const config: Config = {
|
||||||
// ],
|
// ],
|
||||||
|
|
||||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||||
// testPathIgnorePatterns: [
|
testPathIgnorePatterns: ["/e2e/"],
|
||||||
// "/node_modules/"
|
|
||||||
// ],
|
|
||||||
|
|
||||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||||
// testRegex: [],
|
// testRegex: [],
|
||||||
|
|
23
llama_stack/ui/jest.setup.ts
Normal file
23
llama_stack/ui/jest.setup.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
// Import llama-stack-client shims for Node environment
|
||||||
|
import "llama-stack-client/shims/node";
|
||||||
|
|
||||||
|
// Add any other global test setup here
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
|
// Mock ResizeObserver globally
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock IntersectionObserver globally
|
||||||
|
global.IntersectionObserver = class IntersectionObserver {
|
||||||
|
constructor(callback: IntersectionObserverCallback) {}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} as any;
|
|
@ -43,6 +43,33 @@ export interface ChatCompletion {
|
||||||
input_messages: ChatMessage[];
|
input_messages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListChatCompletionsResponse {
|
||||||
|
data: ChatCompletion[];
|
||||||
|
has_more: boolean;
|
||||||
|
first_id: string;
|
||||||
|
last_id: string;
|
||||||
|
object: "list";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaginationStatus = "idle" | "loading" | "loading-more" | "error";
|
||||||
|
|
||||||
|
export interface PaginationState {
|
||||||
|
data: ChatCompletion[];
|
||||||
|
status: PaginationStatus;
|
||||||
|
hasMore: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
lastId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePaginationOptions {
|
||||||
|
/** Number of items to load per page (default: 20) */
|
||||||
|
limit?: number;
|
||||||
|
/** Filter by specific model */
|
||||||
|
model?: string;
|
||||||
|
/** Sort order for results (default: "desc") */
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
// Response types for OpenAI Responses API
|
// Response types for OpenAI Responses API
|
||||||
export interface ResponseInputMessageContent {
|
export interface ResponseInputMessageContent {
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|
|
@ -9,7 +9,8 @@
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "prettier --write \"./**/*.{ts,tsx}\"",
|
"format": "prettier --write \"./**/*.{ts,tsx}\"",
|
||||||
"format:check": "prettier --check \"./**/*.{ts,tsx}\"",
|
"format:check": "prettier --check \"./**/*.{ts,tsx}\"",
|
||||||
"test": "jest"
|
"test": "jest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.13",
|
"@radix-ui/react-dialog": "^1.1.13",
|
||||||
|
|
36
llama_stack/ui/playwright.config.ts
Normal file
36
llama_stack/ui/playwright.config.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: "line",
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: "http://localhost:8322",
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev",
|
||||||
|
url: "http://localhost:8322",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120 * 1000,
|
||||||
|
},
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue