mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-12-04 10:10:36 +00:00
chore: move src/llama_stack/ui to src/llama_stack_ui (#4068)
# What does this PR do? This better separates UI from backend code, which was a point of confusion often for our beloved AI friends. ## Test Plan CI
This commit is contained in:
parent
5850e3473f
commit
95b0493fae
156 changed files with 20 additions and 20 deletions
138
src/llama_stack_ui/components/logs/logs-table-scroll.test.tsx
Normal file
138
src/llama_stack_ui/components/logs/logs-table-scroll.test.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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/use-infinite-scroll", () => ({
|
||||
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();
|
||||
});
|
||||
});
|
||||
371
src/llama_stack_ui/components/logs/logs-table.test.tsx
Normal file
371
src/llama_stack_ui/components/logs/logs-table.test.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { LogsTable, LogTableRow } from "./logs-table";
|
||||
import { PaginationStatus } from "@/lib/types";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = jest.fn();
|
||||
jest.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock helper functions
|
||||
jest.mock("@/lib/truncate-text");
|
||||
|
||||
// Import the mocked functions
|
||||
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
||||
|
||||
// Cast to jest.Mock for typings
|
||||
const truncateText = originalTruncateText as jest.Mock;
|
||||
|
||||
describe("LogsTable", () => {
|
||||
const defaultProps = {
|
||||
data: [] as LogTableRow[],
|
||||
status: "idle" as PaginationStatus,
|
||||
error: null,
|
||||
caption: "Test table caption",
|
||||
emptyMessage: "No data found",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
mockPush.mockClear();
|
||||
truncateText.mockClear();
|
||||
|
||||
// Default pass-through implementation
|
||||
truncateText.mockImplementation((text: string | undefined) => text);
|
||||
});
|
||||
|
||||
test("renders without crashing with default props", () => {
|
||||
render(<LogsTable {...defaultProps} />);
|
||||
expect(screen.getByText("No data found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("click on a row navigates to the correct URL", () => {
|
||||
const mockData: LogTableRow[] = [
|
||||
{
|
||||
id: "row_123",
|
||||
input: "Test input",
|
||||
output: "Test output",
|
||||
model: "test-model",
|
||||
createdTime: "2024-01-01 12:00:00",
|
||||
detailPath: "/test/path/row_123",
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||
|
||||
const row = screen.getByText("Test input").closest("tr");
|
||||
if (row) {
|
||||
fireEvent.click(row);
|
||||
expect(mockPush).toHaveBeenCalledWith("/test/path/row_123");
|
||||
} else {
|
||||
throw new Error('Row with "Test input" not found for router mock test.');
|
||||
}
|
||||
});
|
||||
|
||||
describe("Loading State", () => {
|
||||
test("renders skeleton UI when isLoading is true", () => {
|
||||
const { container } = render(
|
||||
<LogsTable {...defaultProps} status="loading" />
|
||||
);
|
||||
|
||||
// Check for skeleton in the table caption
|
||||
const tableCaption = container.querySelector("caption");
|
||||
expect(tableCaption).toBeInTheDocument();
|
||||
if (tableCaption) {
|
||||
const captionSkeleton = tableCaption.querySelector(
|
||||
'[data-slot="skeleton"]'
|
||||
);
|
||||
expect(captionSkeleton).toBeInTheDocument();
|
||||
}
|
||||
|
||||
// Check for skeletons in the table body cells
|
||||
const tableBody = container.querySelector("tbody");
|
||||
expect(tableBody).toBeInTheDocument();
|
||||
if (tableBody) {
|
||||
const bodySkeletons = tableBody.querySelectorAll(
|
||||
'[data-slot="skeleton"]'
|
||||
);
|
||||
expect(bodySkeletons.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Check that table headers are still rendered
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correct number of skeleton rows", () => {
|
||||
const { container } = render(
|
||||
<LogsTable {...defaultProps} status="loading" />
|
||||
);
|
||||
|
||||
const skeletonRows = container.querySelectorAll("tbody tr");
|
||||
expect(skeletonRows.length).toBe(3); // Should render 3 skeleton rows
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error State", () => {
|
||||
test("renders error message when error prop is provided", () => {
|
||||
const errorMessage = "Network Error";
|
||||
render(
|
||||
<LogsTable
|
||||
{...defaultProps}
|
||||
status="error"
|
||||
error={{ name: "Error", message: errorMessage } as Error}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders default error message when error.message is not available", () => {
|
||||
render(
|
||||
<LogsTable
|
||||
{...defaultProps}
|
||||
status="error"
|
||||
error={{ name: "Error", message: "" } as Error}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("An unexpected error occurred while loading the data.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders default error message when error prop is an object without message", () => {
|
||||
render(
|
||||
<LogsTable {...defaultProps} status="error" error={{} as Error} />
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("An unexpected error occurred while loading the data.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render table when in error state", () => {
|
||||
render(
|
||||
<LogsTable
|
||||
{...defaultProps}
|
||||
status="error"
|
||||
error={{ name: "Error", message: "Test error" } as Error}
|
||||
/>
|
||||
);
|
||||
const table = screen.queryByRole("table");
|
||||
expect(table).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty State", () => {
|
||||
test("renders custom empty message when data array is empty", () => {
|
||||
render(
|
||||
<LogsTable
|
||||
{...defaultProps}
|
||||
data={[]}
|
||||
emptyMessage="Custom empty message"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Custom empty message")).toBeInTheDocument();
|
||||
|
||||
// Ensure that the table structure is NOT rendered in the empty state
|
||||
const table = screen.queryByRole("table");
|
||||
expect(table).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Rendering", () => {
|
||||
test("renders table caption, headers, and data correctly", () => {
|
||||
const mockData: LogTableRow[] = [
|
||||
{
|
||||
id: "row_1",
|
||||
input: "First input",
|
||||
output: "First output",
|
||||
model: "model-1",
|
||||
createdTime: "2024-01-01 12:00:00",
|
||||
detailPath: "/path/1",
|
||||
},
|
||||
{
|
||||
id: "row_2",
|
||||
input: "Second input",
|
||||
output: "Second output",
|
||||
model: "model-2",
|
||||
createdTime: "2024-01-02 13:00:00",
|
||||
detailPath: "/path/2",
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<LogsTable
|
||||
{...defaultProps}
|
||||
data={mockData}
|
||||
caption="Custom table caption"
|
||||
/>
|
||||
);
|
||||
|
||||
// Table caption
|
||||
expect(screen.getByText("Custom table caption")).toBeInTheDocument();
|
||||
|
||||
// Table headers
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created")).toBeInTheDocument();
|
||||
|
||||
// Data rows
|
||||
expect(screen.getByText("First input")).toBeInTheDocument();
|
||||
expect(screen.getByText("First output")).toBeInTheDocument();
|
||||
expect(screen.getByText("model-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("2024-01-01 12:00:00")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Second input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second output")).toBeInTheDocument();
|
||||
expect(screen.getByText("model-2")).toBeInTheDocument();
|
||||
expect(screen.getByText("2024-01-02 13:00:00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("applies correct CSS classes to table rows", () => {
|
||||
const mockData: LogTableRow[] = [
|
||||
{
|
||||
id: "row_1",
|
||||
input: "Test input",
|
||||
output: "Test output",
|
||||
model: "test-model",
|
||||
createdTime: "2024-01-01 12:00:00",
|
||||
detailPath: "/test/path",
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||
|
||||
const row = screen.getByText("Test input").closest("tr");
|
||||
expect(row).toHaveClass("cursor-pointer");
|
||||
expect(row).toHaveClass("hover:bg-muted/50");
|
||||
});
|
||||
|
||||
test("applies correct alignment to Created column", () => {
|
||||
const mockData: LogTableRow[] = [
|
||||
{
|
||||
id: "row_1",
|
||||
input: "Test input",
|
||||
output: "Test output",
|
||||
model: "test-model",
|
||||
createdTime: "2024-01-01 12:00:00",
|
||||
detailPath: "/test/path",
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||
|
||||
const createdCell = screen.getByText("2024-01-01 12:00:00").closest("td");
|
||||
expect(createdCell).toHaveClass("text-right");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Text Truncation", () => {
|
||||
test("truncates input and output text using truncateText function", () => {
|
||||
// Mock truncateText to return truncated versions
|
||||
truncateText.mockImplementation((text: string | undefined) => {
|
||||
if (typeof text === "string" && text.length > 10) {
|
||||
return text.slice(0, 10) + "...";
|
||||
}
|
||||
return text;
|
||||
});
|
||||
|
||||
const longInput =
|
||||
"This is a very long input text that should be truncated";
|
||||
const longOutput =
|
||||
"This is a very long output text that should be truncated";
|
||||
|
||||
const mockData: LogTableRow[] = [
|
||||
{
|
||||
id: "row_1",
|
||||
input: longInput,
|
||||
output: longOutput,
|
||||
model: "test-model",
|
||||
createdTime: "2024-01-01 12:00:00",
|
||||
detailPath: "/test/path",
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||
|
||||
// Verify truncateText was called
|
||||
expect(truncateText).toHaveBeenCalledWith(longInput);
|
||||
expect(truncateText).toHaveBeenCalledWith(longOutput);
|
||||
|
||||
// Verify truncated text is displayed
|
||||
const truncatedTexts = screen.getAllByText("This is a ...");
|
||||
expect(truncatedTexts).toHaveLength(2); // one for input, one for output
|
||||
truncatedTexts.forEach(textElement =>
|
||||
expect(textElement).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("does not truncate model names", () => {
|
||||
const mockData: LogTableRow[] = [
|
||||
{
|
||||
id: "row_1",
|
||||
input: "Test input",
|
||||
output: "Test output",
|
||||
model: "very-long-model-name-that-should-not-be-truncated",
|
||||
createdTime: "2024-01-01 12:00:00",
|
||||
detailPath: "/test/path",
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||
|
||||
// Model name should not be passed to truncateText
|
||||
expect(truncateText).not.toHaveBeenCalledWith(
|
||||
"very-long-model-name-that-should-not-be-truncated"
|
||||
);
|
||||
|
||||
// Full model name should be displayed
|
||||
expect(
|
||||
screen.getByText("very-long-model-name-that-should-not-be-truncated")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
test("table has proper role and structure", () => {
|
||||
const mockData: LogTableRow[] = [
|
||||
{
|
||||
id: "row_1",
|
||||
input: "Test input",
|
||||
output: "Test output",
|
||||
model: "test-model",
|
||||
createdTime: "2024-01-01 12:00:00",
|
||||
detailPath: "/test/path",
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogsTable {...defaultProps} data={mockData} />);
|
||||
|
||||
const tables = screen.getAllByRole("table");
|
||||
expect(tables).toHaveLength(2); // Fixed header table + body table
|
||||
|
||||
const columnHeaders = screen.getAllByRole("columnheader");
|
||||
expect(columnHeaders).toHaveLength(4);
|
||||
|
||||
const rows = screen.getAllByRole("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();
|
||||
});
|
||||
});
|
||||
});
|
||||
195
src/llama_stack_ui/components/logs/logs-table.tsx
Normal file
195
src/llama_stack_ui/components/logs/logs-table.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef } from "react";
|
||||
import { truncateText } from "@/lib/truncate-text";
|
||||
import { PaginationStatus } from "@/lib/types";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// Generic table row data interface
|
||||
export interface LogTableRow {
|
||||
id: string;
|
||||
input: string;
|
||||
output: string;
|
||||
model: string;
|
||||
createdTime: string;
|
||||
detailPath: string;
|
||||
}
|
||||
|
||||
interface LogsTableProps {
|
||||
/** Array of log table row data to display */
|
||||
data: LogTableRow[];
|
||||
/** Current loading/error status */
|
||||
status: PaginationStatus;
|
||||
/** Whether more data is available to load */
|
||||
hasMore?: boolean;
|
||||
/** Error state, null if no error */
|
||||
error: Error | null;
|
||||
/** Table caption for accessibility */
|
||||
caption: string;
|
||||
/** Message to show when no data is available */
|
||||
emptyMessage: string;
|
||||
/** Callback function to load more data */
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
export function LogsTable({
|
||||
data,
|
||||
status,
|
||||
hasMore = false,
|
||||
error,
|
||||
caption,
|
||||
emptyMessage,
|
||||
onLoadMore,
|
||||
}: LogsTableProps) {
|
||||
const router = useRouter();
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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>
|
||||
<TableRow>
|
||||
<TableHead className="w-1/4">Input</TableHead>
|
||||
<TableHead className="w-1/4">Output</TableHead>
|
||||
<TableHead className="w-1/4">Model</TableHead>
|
||||
<TableHead className="w-1/4 text-right">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<FixedHeader />
|
||||
<div ref={tableContainerRef} className="overflow-auto flex-1 min-h-0">
|
||||
<Table>
|
||||
<TableCaption>
|
||||
<Skeleton className="h-4 w-[250px] mx-auto" />
|
||||
</TableCaption>
|
||||
<TableBody>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<TableRow key={`skeleton-${i}`}>
|
||||
<TableCell className="w-1/4">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-1/4">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-1/4">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</TableCell>
|
||||
<TableCell className="w-1/4 text-right">
|
||||
<Skeleton className="h-4 w-1/2 ml-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <p>{emptyMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<FixedHeader />
|
||||
<div ref={tableContainerRef} className="overflow-auto flex-1 min-h-0">
|
||||
<Table>
|
||||
<TableCaption className="sr-only">{caption}</TableCaption>
|
||||
<TableBody>
|
||||
{data.map(row => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => router.push(row.detailPath)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="w-1/4">
|
||||
{truncateText(row.input)}
|
||||
</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>
|
||||
))}
|
||||
{/* 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>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue