mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
refactor(ui): share CopyButton between Lens traces and logs (#44513)
Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
1459e00430
commit
e1d16f51d1
7 changed files with 197 additions and 90 deletions
|
|
@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
import { AttributesDetail } from "./AttributesDetail";
|
||||
import { CopyButton } from "../ui/CopyButton";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { DetailContent, errorHeadline, useSpanDetail } from "./DetailContent";
|
||||
import { IdChip } from "../ui/IdChip";
|
||||
import { PaneBar } from "../ui/PaneBar";
|
||||
|
|
@ -144,7 +144,7 @@ function SpanPane({
|
|||
</TabsContent>
|
||||
</Tabs>
|
||||
<PaneFooter>
|
||||
<CopyButton value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />
|
||||
<CopyButton variant="action" value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />
|
||||
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground tabular-nums">
|
||||
<Meta label="time" value={fmtMs(span.duration_ms)} />
|
||||
{tokens > 0 && <Meta label="tokens" value={fmtTok(tokens)} />}
|
||||
|
|
@ -209,7 +209,7 @@ function GroupPane({
|
|||
)}
|
||||
</div>
|
||||
<PaneFooter>
|
||||
<CopyButton value={handoff.text} label="Copy group sample" copiedLabel={handoff.copied} />
|
||||
<CopyButton variant="action" value={handoff.text} label="Copy group sample" copiedLabel={handoff.copied} />
|
||||
</PaneFooter>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import remarkGfm from "remark-gfm";
|
|||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { FoldChevron } from "../ui/Collapse";
|
||||
import { CopyButton } from "../ui/CopyButton";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { displayValue, type KeyValue, KeyValueRows, objectEntries } from "./KeyValueRows";
|
||||
import type { TraceMessage, TraceToolCall } from "../types";
|
||||
|
||||
|
|
@ -94,7 +94,13 @@ export function ToolCallBlock({ call }: { call: TraceToolCall }) {
|
|||
<div className="flex items-center gap-2 py-1">
|
||||
<RoleTile role="tool" />
|
||||
<span className="min-w-0 truncate text-sm font-medium">{call.name}</span>
|
||||
<CopyButton value={call.name} label={`Copy ${call.name} name`} iconOnly className={INLINE_COPY} />
|
||||
<CopyButton
|
||||
variant="action"
|
||||
value={call.name}
|
||||
label={`Copy ${call.name} name`}
|
||||
iconOnly
|
||||
className={INLINE_COPY}
|
||||
/>
|
||||
</div>
|
||||
<KeyValueRows entries={entries} />
|
||||
</div>
|
||||
|
|
@ -114,7 +120,7 @@ export function MessageCard({ message }: { message: TraceMessage; model: string
|
|||
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-sm border-b-0" : "rounded-sm")}>
|
||||
<FoldTile label={label} open={open} onToggle={() => setOpen((v) => !v)} />
|
||||
<span className={LABEL}>{label}</span>
|
||||
<CopyButton value={copyValue} label={`Copy ${label}`} iconOnly className={CARD_COPY} />
|
||||
<CopyButton variant="action" value={copyValue} label={`Copy ${label}`} iconOnly className={CARD_COPY} />
|
||||
</div>
|
||||
{expanded && (
|
||||
<div
|
||||
|
|
@ -146,7 +152,7 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
|
|||
<RoleTile role="tool" failed={failed} />
|
||||
<span className="flex min-w-0 max-w-[50%] shrink-0 items-center gap-1.5">
|
||||
<span className={cn(LABEL, failed && "text-destructive")}>{name}</span>
|
||||
<CopyButton value={name} label={`Copy ${name} name`} iconOnly className={INLINE_COPY} />
|
||||
<CopyButton variant="action" value={name} label={`Copy ${name} name`} iconOnly className={INLINE_COPY} />
|
||||
</span>
|
||||
{expandable ? (
|
||||
<button
|
||||
|
|
@ -162,7 +168,7 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
|
|||
) : (
|
||||
<span className={cn("min-w-0 break-words text-sm leading-5", tone)}>{result || "No output"}</span>
|
||||
)}
|
||||
<CopyButton value={result} label={`Copy ${name} result`} iconOnly className={CARD_COPY} />
|
||||
<CopyButton variant="action" value={result} label={`Copy ${name} result`} iconOnly className={CARD_COPY} />
|
||||
</div>
|
||||
{open && (
|
||||
<pre
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { LogDetailsDrawer } from "../../../logs/detail";
|
||||
import { formatCost } from "../list/AgentTracesTable";
|
||||
import { DetailGroup } from "./AttributesDetail";
|
||||
import { CopyButton } from "../ui/CopyButton";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { type KeyValue, KeyValueRows } from "./KeyValueRows";
|
||||
import type { Span } from "../types";
|
||||
import { fmtMs, fmtTok } from "../utils";
|
||||
|
|
@ -52,7 +52,7 @@ export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetail
|
|||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<KeyValueRows entries={[["request_id", span.litellm_request_id]]} mono className="min-w-0 flex-1" />
|
||||
<CopyButton value={span.litellm_request_id} label="Copy request ID" iconOnly />
|
||||
<CopyButton variant="action" value={span.litellm_request_id} label="Copy request ID" iconOnly />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
|
|||
import { useState } from "react";
|
||||
import { ChevronRight, Wrench } from "lucide-react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { CopyButton } from "../ui/CopyButton";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { useTracesApi } from "../api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildConversation, conversationSteps, CONVERSATION_PAGE_SIZE, type ConversationItem } from "./conversation";
|
||||
|
|
@ -144,7 +144,12 @@ function ConversationTool({ item }: { item: ConversationItem }) {
|
|||
)}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Result</span>
|
||||
<CopyButton value={item.toolResult ?? ""} label={`Copy ${item.span.name} result`} iconOnly />
|
||||
<CopyButton
|
||||
variant="action"
|
||||
value={item.toolResult ?? ""}
|
||||
label={`Copy ${item.span.name} result`}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words text-xs leading-5">
|
||||
{item.toolResult || "No output recorded"}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
const COPIED_RESET_MS = 1600;
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string;
|
||||
label?: string;
|
||||
copiedLabel?: string;
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Copy → check for a moment. `iconOnly` renders a bare icon button with a tooltip. */
|
||||
export function CopyButton({
|
||||
value,
|
||||
label = "Copy",
|
||||
copiedLabel = "Copied",
|
||||
iconOnly = false,
|
||||
className,
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timeout = window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [copied]);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant={iconOnly ? "ghost" : "outline"}
|
||||
size={iconOnly ? "icon-xs" : "xs"}
|
||||
className={cn("text-xs text-muted-foreground hover:text-foreground", !iconOnly && "gap-1.5", className)}
|
||||
onClick={async () => setCopied(await copyToClipboard(value, copiedLabel))}
|
||||
aria-label={label}
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{!iconOnly && <span>{copied ? copiedLabel : label}</span>}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!iconOnly) return button;
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent>{copied ? copiedLabel : label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,31 @@
|
|||
import { act, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ComponentProps } from "react";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
|
||||
import CopyButton from "./CopyButton";
|
||||
|
||||
vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn() }));
|
||||
vi.mock("lucide-react", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("lucide-react")>()),
|
||||
Copy: (props: ComponentProps<"svg">) => <svg {...props} role="img" aria-label="Copy icon" />,
|
||||
Check: (props: ComponentProps<"svg">) => <svg {...props} role="img" aria-label="Copied icon" />,
|
||||
}));
|
||||
|
||||
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.resetAllMocks();
|
||||
if (clipboardDescriptor) Object.defineProperty(navigator, "clipboard", clipboardDescriptor);
|
||||
else Reflect.deleteProperty(navigator, "clipboard");
|
||||
});
|
||||
|
||||
describe("CopyButton", () => {
|
||||
it("renders nothing when there is no value", () => {
|
||||
const { container } = renderWithProviders(<CopyButton value={null} label="Copy value" />);
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
renderWithProviders(<CopyButton value={null} label="Copy value" />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the confirmation checkmark only after a successful write", async () => {
|
||||
|
|
@ -17,12 +36,12 @@ describe("CopyButton", () => {
|
|||
renderWithProviders(<CopyButton value="secret-value" label="Copy value" />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Copy value" });
|
||||
expect(button.querySelector(".lucide-copy")).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
|
||||
|
||||
await user.click(button);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("secret-value");
|
||||
await waitFor(() => expect(button.querySelector(".lucide-check")).toBeInTheDocument());
|
||||
expect(await screen.findByRole("img", { name: "Copied icon" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the checkmark when the clipboard write is rejected", async () => {
|
||||
|
|
@ -36,8 +55,8 @@ describe("CopyButton", () => {
|
|||
await user.click(button);
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith("secret-value"));
|
||||
expect(button.querySelector(".lucide-check")).not.toBeInTheDocument();
|
||||
expect(button.querySelector(".lucide-copy")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the checkmark when the clipboard API is unavailable", async () => {
|
||||
|
|
@ -49,7 +68,96 @@ describe("CopyButton", () => {
|
|||
const button = screen.getByRole("button", { name: "Copy value" });
|
||||
await user.click(button);
|
||||
|
||||
expect(button.querySelector(".lucide-check")).not.toBeInTheDocument();
|
||||
expect(button.querySelector(".lucide-copy")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the default icon styling, custom classes, title and 1200ms confirmation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
||||
renderWithProviders(
|
||||
<CopyButton value="request-id" label="Copy request ID" className="shrink-0" iconClassName="size-3" />,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Copy request ID" });
|
||||
expect(button).toHaveAttribute("title", "Copy request ID");
|
||||
expect(button).toHaveClass("hover:text-primary", "shrink-0");
|
||||
expect(button).not.toHaveTextContent(/.+/);
|
||||
expect(screen.getByRole("img", { name: "Copy icon" })).toHaveClass("size-3");
|
||||
|
||||
await act(async () => fireEvent.click(button));
|
||||
expect(writeText).toHaveBeenCalledWith("request-id");
|
||||
expect(copyToClipboard).not.toHaveBeenCalled();
|
||||
act(() => vi.advanceTimersByTime(1199));
|
||||
expect(screen.getByRole("img", { name: "Copied icon" })).toBeInTheDocument();
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an outlined action with custom confirmation text for 1600ms", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(copyToClipboard).mockResolvedValue(true);
|
||||
renderWithProviders(
|
||||
<CopyButton variant="action" value="step-context" label="Copy step" copiedLabel="Step copied" />,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Copy step" });
|
||||
expect(button).toHaveTextContent("Copy step");
|
||||
expect(button).toHaveClass("border", "gap-1.5", "hover:text-foreground");
|
||||
expect(screen.getByRole("img", { name: "Copy icon" })).toHaveClass("size-3");
|
||||
|
||||
await act(async () => fireEvent.click(button));
|
||||
expect(copyToClipboard).toHaveBeenCalledWith("step-context", "Step copied");
|
||||
expect(button).toHaveTextContent("Step copied");
|
||||
expect(screen.getByRole("img", { name: "Copied icon" })).toBeInTheDocument();
|
||||
act(() => vi.advanceTimersByTime(1599));
|
||||
expect(button).toHaveTextContent("Step copied");
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(button).toHaveTextContent("Copy step");
|
||||
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a tooltip and no text for an icon-only action", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(copyToClipboard).mockResolvedValue(true);
|
||||
renderWithProviders(<CopyButton variant="action" value="tool-result" label="Copy tool result" iconOnly />);
|
||||
const button = screen.getByRole("button", { name: "Copy tool result" });
|
||||
expect(button).not.toHaveTextContent(/.+/);
|
||||
expect(button).not.toHaveAttribute("title");
|
||||
await user.hover(button);
|
||||
expect(await screen.findByText("Copy tool result")).toBeVisible();
|
||||
await user.click(button);
|
||||
expect(copyToClipboard).toHaveBeenCalledWith("tool-result", "Copied");
|
||||
await user.unhover(button);
|
||||
await user.hover(button);
|
||||
expect(await screen.findByText("Copied")).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps the action label and copy icon when copying fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(copyToClipboard).mockResolvedValue(false);
|
||||
renderWithProviders(<CopyButton variant="action" value="step-context" label="Copy step" />);
|
||||
const button = screen.getByRole("button", { name: "Copy step" });
|
||||
await user.click(button);
|
||||
expect(copyToClipboard).toHaveBeenCalledWith("step-context", "Copied");
|
||||
expect(button).toHaveTextContent("Copy step");
|
||||
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retains an empty action while default empty values remain hidden", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(copyToClipboard).mockResolvedValue(false);
|
||||
renderWithProviders(
|
||||
<>
|
||||
<CopyButton value="" label="Copy value" />
|
||||
<CopyButton variant="action" value="" label="Copy empty result" iconOnly />
|
||||
</>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Copy value" })).not.toBeInTheDocument();
|
||||
const button = screen.getByRole("button", { name: "Copy empty result" });
|
||||
await user.click(button);
|
||||
expect(copyToClipboard).toHaveBeenCalledWith("", "Copied");
|
||||
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
|
|
@ -8,20 +12,52 @@ interface CopyButtonProps {
|
|||
label: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
variant?: "icon" | "action";
|
||||
iconOnly?: boolean;
|
||||
copiedLabel?: string;
|
||||
}
|
||||
|
||||
const CopyButton: React.FC<CopyButtonProps> = ({ value, label, className, iconClassName = "size-[15px]" }) => {
|
||||
const VARIANTS = {
|
||||
icon: {
|
||||
resetMs: 1200,
|
||||
iconClassName: "size-[15px]",
|
||||
className: "text-muted-foreground hover:text-primary",
|
||||
},
|
||||
action: {
|
||||
resetMs: 1600,
|
||||
iconClassName: "size-3",
|
||||
className: "text-xs text-muted-foreground hover:text-foreground",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
value,
|
||||
label,
|
||||
className,
|
||||
iconClassName,
|
||||
variant = "icon",
|
||||
iconOnly = false,
|
||||
copiedLabel = "Copied",
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isAction = variant === "action";
|
||||
const showLabel = isAction && !iconOnly;
|
||||
const { resetMs, className: variantClassName, iconClassName: defaultIconClassName } = VARIANTS[variant];
|
||||
const iconSize = iconClassName ?? defaultIconClassName;
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timer = setTimeout(() => setCopied(false), 1200);
|
||||
const timer = setTimeout(() => setCopied(false), resetMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
}, [copied, resetMs]);
|
||||
|
||||
if (!value) return null;
|
||||
if (value == null || (!value && !isAction)) return null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (isAction) {
|
||||
setCopied(await copyToClipboard(value, copiedLabel));
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
|
|
@ -31,19 +67,30 @@ const CopyButton: React.FC<CopyButtonProps> = ({ value, label, className, iconCl
|
|||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
variant={showLabel ? "outline" : "ghost"}
|
||||
size={showLabel ? "xs" : "icon-xs"}
|
||||
onClick={handleCopy}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn("text-muted-foreground hover:text-primary", className)}
|
||||
title={isAction ? undefined : label}
|
||||
className={cn(variantClassName, showLabel && "gap-1.5", className)}
|
||||
>
|
||||
{copied ? <Check className={iconClassName} /> : <Copy className={iconClassName} />}
|
||||
{copied ? <Check className={iconSize} /> : <Copy className={iconSize} />}
|
||||
{showLabel && <span>{copied ? copiedLabel : label}</span>}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!isAction || !iconOnly) return button;
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent>{copied ? copiedLabel : label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue