mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: implement new workspace download logs dropdown (#23963)
This PR improves the agent log download functionality by replacing the single download button with a comprehensive dropdown menu system. - Replaced single download button with a dropdown menu offering multiple download options - Added ability to download all logs or individual log sources separately - Updated download button to show chevron icon indicating dropdown functionality - Enhanced download options with appropriate icons for each log source <img width="370" height="305" alt="image" src="https://github.com/user-attachments/assets/ddf025f5-f936-499a-9165-6e81b62d6860" />
This commit is contained in:
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type TabLike = {
|
||||
value: string;
|
||||
@@ -38,97 +30,85 @@ export const useTabOverflowKebabMenu = <TTab extends TabLike>({
|
||||
}: UseTabOverflowKebabMenuOptions<TTab>): UseTabOverflowKebabMenuResult<TTab> => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tabWidthByValueRef = useRef<Record<string, number>>({});
|
||||
const tabsRef = useRef(tabs);
|
||||
const [overflowTabValues, setOverflowTabValues] = useState<string[]>([]);
|
||||
|
||||
const recalculateOverflow = useCallback(() => {
|
||||
if (!enabled) {
|
||||
setOverflowTabValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const tab of tabs) {
|
||||
const tabElement = container.querySelector<HTMLElement>(
|
||||
`[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`,
|
||||
);
|
||||
if (tabElement) {
|
||||
tabWidthByValueRef.current[tab.value] = tabElement.offsetWidth;
|
||||
}
|
||||
}
|
||||
|
||||
const alwaysVisibleTabs = tabs.slice(0, alwaysVisibleTabsCount);
|
||||
const optionalTabs = tabs.slice(alwaysVisibleTabsCount);
|
||||
if (optionalTabs.length === 0) {
|
||||
setOverflowTabValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => {
|
||||
return total + (tabWidthByValueRef.current[tab.value] ?? 0);
|
||||
}, 0);
|
||||
|
||||
const availableWidth = container.clientWidth;
|
||||
let usedWidth = alwaysVisibleWidth;
|
||||
const nextOverflowValues: string[] = [];
|
||||
|
||||
for (let i = 0; i < optionalTabs.length; i++) {
|
||||
const tab = optionalTabs[i];
|
||||
const tabWidth = tabWidthByValueRef.current[tab.value] ?? 0;
|
||||
const hasMoreTabsAfterCurrent = i < optionalTabs.length - 1;
|
||||
const widthNeeded =
|
||||
usedWidth +
|
||||
tabWidth +
|
||||
(hasMoreTabsAfterCurrent ? overflowTriggerWidthPx : 0);
|
||||
|
||||
if (widthNeeded <= availableWidth) {
|
||||
usedWidth += tabWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextOverflowValues.push(
|
||||
...optionalTabs.slice(i).map((overflowTab) => overflowTab.value),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
setOverflowTabValues((currentValues) => {
|
||||
if (
|
||||
currentValues.length === nextOverflowValues.length &&
|
||||
currentValues.every(
|
||||
(value, index) => value === nextOverflowValues[index],
|
||||
)
|
||||
) {
|
||||
return currentValues;
|
||||
}
|
||||
return nextOverflowValues;
|
||||
});
|
||||
}, [alwaysVisibleTabsCount, enabled, overflowTriggerWidthPx, tabs]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
recalculateOverflow();
|
||||
}, [isActive, recalculateOverflow]);
|
||||
tabsRef.current = tabs;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
if (!enabled || !isActive) {
|
||||
setOverflowTabValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
recalculateOverflow();
|
||||
});
|
||||
|
||||
const recalculateOverflow = () => {
|
||||
const currentTabs = tabsRef.current;
|
||||
for (const tab of currentTabs) {
|
||||
const tabElement = container.querySelector<HTMLElement>(
|
||||
`[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`,
|
||||
);
|
||||
if (tabElement) {
|
||||
tabWidthByValueRef.current[tab.value] = tabElement.offsetWidth;
|
||||
}
|
||||
}
|
||||
|
||||
const alwaysVisibleTabs = currentTabs.slice(0, alwaysVisibleTabsCount);
|
||||
const optionalTabs = currentTabs.slice(alwaysVisibleTabsCount);
|
||||
if (optionalTabs.length === 0) {
|
||||
setOverflowTabValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => {
|
||||
return total + (tabWidthByValueRef.current[tab.value] ?? 0);
|
||||
}, 0);
|
||||
|
||||
const availableWidth = container.clientWidth;
|
||||
let usedWidth = alwaysVisibleWidth;
|
||||
const nextOverflowValues: string[] = [];
|
||||
|
||||
for (let i = 0; i < optionalTabs.length; i++) {
|
||||
const tab = optionalTabs[i];
|
||||
const tabWidth = tabWidthByValueRef.current[tab.value] ?? 0;
|
||||
const hasMoreTabsAfterCurrent = i < optionalTabs.length - 1;
|
||||
const widthNeeded =
|
||||
usedWidth +
|
||||
tabWidth +
|
||||
(hasMoreTabsAfterCurrent ? overflowTriggerWidthPx : 0);
|
||||
|
||||
if (widthNeeded <= availableWidth) {
|
||||
usedWidth += tabWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextOverflowValues.push(
|
||||
...optionalTabs.slice(i).map((overflowTab) => overflowTab.value),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
setOverflowTabValues((currentValues) => {
|
||||
if (
|
||||
currentValues.length === nextOverflowValues.length &&
|
||||
currentValues.every(
|
||||
(value, index) => value === nextOverflowValues[index],
|
||||
)
|
||||
) {
|
||||
return currentValues;
|
||||
}
|
||||
return nextOverflowValues;
|
||||
});
|
||||
};
|
||||
|
||||
recalculateOverflow();
|
||||
const observer = new ResizeObserver(recalculateOverflow);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [isActive, recalculateOverflow]);
|
||||
}, [alwaysVisibleTabsCount, enabled, isActive, overflowTriggerWidthPx]);
|
||||
|
||||
const overflowTabValuesSet = useMemo(
|
||||
() => new Set(overflowTabValues),
|
||||
@@ -144,9 +124,9 @@ export const useTabOverflowKebabMenu = <TTab extends TabLike>({
|
||||
[tabs, overflowTabValuesSet],
|
||||
);
|
||||
|
||||
const getTabMeasureProps = useCallback((tabValue: string) => {
|
||||
const getTabMeasureProps = (tabValue: string) => {
|
||||
return { [DATA_ATTR_TAB_VALUE]: tabValue };
|
||||
}, []);
|
||||
};
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { API } from "#/api/api";
|
||||
import { workspaceAgentContainersKey } from "#/api/queries/workspaces";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type { WorkspaceAgentLogSource } from "#/api/typesGenerated";
|
||||
import { getPreferredProxy } from "#/contexts/ProxyContext";
|
||||
import { chromatic } from "#/testHelpers/chromatic";
|
||||
import * as M from "#/testHelpers/entities";
|
||||
@@ -92,7 +92,7 @@ const logs = [
|
||||
created_at: fixedLogTimestamp,
|
||||
}));
|
||||
|
||||
const installScriptLogSource: TypesGen.WorkspaceAgentLogSource = {
|
||||
const installScriptLogSource: WorkspaceAgentLogSource = {
|
||||
...M.MockWorkspaceAgentLogSource,
|
||||
id: "f2ee4b8d-b09d-4f4e-a1f1-5e4adf7d53bb",
|
||||
display_name: "Install Script",
|
||||
@@ -122,42 +122,6 @@ const tabbedLogs = [
|
||||
},
|
||||
];
|
||||
|
||||
const overflowLogSources: TypesGen.WorkspaceAgentLogSource[] = [
|
||||
M.MockWorkspaceAgentLogSource,
|
||||
{
|
||||
...M.MockWorkspaceAgentLogSource,
|
||||
id: "58f5db69-5f78-496f-bce1-0686f5525aa1",
|
||||
display_name: "code-server",
|
||||
icon: "/icon/code.svg",
|
||||
},
|
||||
{
|
||||
...M.MockWorkspaceAgentLogSource,
|
||||
id: "f39d758c-bce2-4f41-8d70-58fdb1f0f729",
|
||||
display_name: "Install and start AgentAPI",
|
||||
icon: "/icon/claude.svg",
|
||||
},
|
||||
{
|
||||
...M.MockWorkspaceAgentLogSource,
|
||||
id: "bf7529b8-1787-4a20-b54f-eb894680e48f",
|
||||
display_name: "Mux",
|
||||
icon: "/icon/mux.svg",
|
||||
},
|
||||
{
|
||||
...M.MockWorkspaceAgentLogSource,
|
||||
id: "0d6ebde6-c534-4551-9f91-bfd98bfb04f4",
|
||||
display_name: "Portable Desktop",
|
||||
icon: "/icon/portable-desktop.svg",
|
||||
},
|
||||
];
|
||||
|
||||
const overflowLogs = overflowLogSources.map((source, index) => ({
|
||||
id: 200 + index,
|
||||
level: "info",
|
||||
output: `${source.display_name}: line`,
|
||||
source_id: source.id,
|
||||
created_at: fixedLogTimestamp,
|
||||
}));
|
||||
|
||||
const meta: Meta<typeof AgentRow> = {
|
||||
title: "components/AgentRow",
|
||||
component: AgentRow,
|
||||
@@ -440,44 +404,3 @@ export const LogsTabs: Story = {
|
||||
await expect(canvas.getByText("install: pnpm install")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const LogsTabsOverflow: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
...M.MockWorkspaceAgentReady,
|
||||
logs_length: overflowLogs.length,
|
||||
log_sources: overflowLogSources,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
webSocket: [
|
||||
{
|
||||
event: "message",
|
||||
data: JSON.stringify(overflowLogs),
|
||||
},
|
||||
],
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="max-w-[320px]">
|
||||
<AgentRow {...args} />
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Logs" }));
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "More log tabs" }),
|
||||
);
|
||||
const overflowItems = await page.findAllByRole("menuitemradio");
|
||||
const selectedItem = overflowItems[0];
|
||||
const selectedSource = selectedItem.textContent;
|
||||
if (!selectedSource) {
|
||||
throw new Error("Overflow menu item must have text content.");
|
||||
}
|
||||
await userEvent.click(selectedItem);
|
||||
await waitFor(() =>
|
||||
expect(canvas.getByText(`${selectedSource}: line`)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,10 +8,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -162,7 +161,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
// This is a bit of a hack on the react-window API to get the scroll position.
|
||||
// If we're scrolled to the bottom, we want to keep the list scrolled to the bottom.
|
||||
// This makes it feel similar to a terminal that auto-scrolls downwards!
|
||||
const handleLogScroll = useCallback((props: ListOnScrollProps) => {
|
||||
const handleLogScroll = (props: ListOnScrollProps) => {
|
||||
if (
|
||||
props.scrollOffset === 0 ||
|
||||
props.scrollUpdateWasRequested ||
|
||||
@@ -179,7 +178,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
logListDivRef.current.scrollHeight -
|
||||
(props.scrollOffset + parent.clientHeight);
|
||||
setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const devcontainers = useAgentContainers(agent);
|
||||
|
||||
@@ -211,49 +210,47 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
);
|
||||
|
||||
const [selectedLogTab, setSelectedLogTab] = useState("all");
|
||||
const logTabs = useMemo(() => {
|
||||
const sourceLogTabs = agent.log_sources
|
||||
.filter((logSource) => {
|
||||
// Remove the logSources that have no entries.
|
||||
return agentLogs.some(
|
||||
(log) =>
|
||||
log.source_id === logSource.id && (log.output?.length ?? 0) > 0,
|
||||
);
|
||||
})
|
||||
.map((logSource) => ({
|
||||
// Show the icon for the log source if it has one.
|
||||
// In the startup script case, we show a bespoke play icon.
|
||||
startIcon: logSource.icon ? (
|
||||
<ExternalImage
|
||||
src={logSource.icon}
|
||||
alt=""
|
||||
className="size-icon-xs shrink-0"
|
||||
/>
|
||||
) : logSource.display_name === STARTUP_SCRIPT_DISPLAY_NAME ? (
|
||||
<PlayIcon className="size-icon-xs shrink-0" />
|
||||
) : null,
|
||||
title: logSource.display_name,
|
||||
value: logSource.id,
|
||||
}));
|
||||
const startupScriptLogTab = sourceLogTabs.find(
|
||||
(tab) => tab.title === STARTUP_SCRIPT_DISPLAY_NAME,
|
||||
);
|
||||
const sortedSourceLogTabs = sourceLogTabs
|
||||
.filter((tab) => tab !== startupScriptLogTab)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
return [
|
||||
{
|
||||
title: "All Logs",
|
||||
value: "all",
|
||||
},
|
||||
...(startupScriptLogTab ? [startupScriptLogTab] : []),
|
||||
...sortedSourceLogTabs,
|
||||
] as {
|
||||
startIcon?: React.ReactNode;
|
||||
title: string;
|
||||
value: string;
|
||||
}[];
|
||||
}, [agent.log_sources, agentLogs]);
|
||||
const sourceLogTabs = agent.log_sources
|
||||
.filter((logSource) => {
|
||||
// Remove the logSources that have no entries.
|
||||
return agentLogs.some(
|
||||
(log) =>
|
||||
log.source_id === logSource.id && (log.output?.length ?? 0) > 0,
|
||||
);
|
||||
})
|
||||
.map((logSource) => ({
|
||||
// Show the icon for the log source if it has one.
|
||||
// In the startup script case, we show a bespoke play icon.
|
||||
startIcon: logSource.icon ? (
|
||||
<ExternalImage
|
||||
src={logSource.icon}
|
||||
alt=""
|
||||
className="size-icon-xs shrink-0"
|
||||
/>
|
||||
) : logSource.display_name === STARTUP_SCRIPT_DISPLAY_NAME ? (
|
||||
<PlayIcon className="size-icon-xs shrink-0" />
|
||||
) : null,
|
||||
title: logSource.display_name,
|
||||
value: logSource.id,
|
||||
}));
|
||||
const startupScriptLogTab = sourceLogTabs.find(
|
||||
(tab) => tab.title === STARTUP_SCRIPT_DISPLAY_NAME,
|
||||
);
|
||||
const sortedSourceLogTabs = sourceLogTabs
|
||||
.filter((tab) => tab !== startupScriptLogTab)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
const logTabs: {
|
||||
startIcon?: ReactNode;
|
||||
title: string;
|
||||
value: string;
|
||||
}[] = [
|
||||
{
|
||||
title: "All Logs",
|
||||
value: "all",
|
||||
},
|
||||
...(startupScriptLogTab ? [startupScriptLogTab] : []),
|
||||
...sortedSourceLogTabs,
|
||||
];
|
||||
const {
|
||||
containerRef: logTabsListContainerRef,
|
||||
visibleTabs: visibleLogTabs,
|
||||
@@ -279,16 +276,29 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
level: log.level,
|
||||
sourceId: log.source_id,
|
||||
}));
|
||||
const allLogsText = agentLogs.map((log) => log.output).join("\n");
|
||||
const selectedLogsText = selectedLogs.map((log) => log.output).join("\n");
|
||||
const hasSelectedLogs = selectedLogs.length > 0;
|
||||
const hasAnyLogs = agentLogs.length > 0;
|
||||
const { showCopiedSuccess, copyToClipboard } = useClipboard();
|
||||
const selectedLogTabTitle =
|
||||
logTabs.find((tab) => tab.value === selectedLogTab)?.title ?? "Logs";
|
||||
const sanitizedTabTitle = selectedLogTabTitle
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const logFilenameSuffix = sanitizedTabTitle || "logs";
|
||||
const downloadableLogSets = logTabs
|
||||
.filter((tab) => tab.value !== "all")
|
||||
.map((tab) => {
|
||||
const logsText = agentLogs
|
||||
.filter((log) => log.source_id === tab.value)
|
||||
.map((log) => log.output)
|
||||
.join("\n");
|
||||
const filenameSuffix = tab.title
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
return {
|
||||
label: tab.title,
|
||||
filenameSuffix: filenameSuffix || tab.value,
|
||||
logsText,
|
||||
startIcon: tab.startIcon,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -547,9 +557,9 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
</Button>
|
||||
<DownloadSelectedAgentLogsButton
|
||||
agentName={agent.name}
|
||||
filenameSuffix={logFilenameSuffix}
|
||||
logsText={selectedLogsText}
|
||||
disabled={!hasSelectedLogs}
|
||||
logSets={downloadableLogSets}
|
||||
allLogsText={allLogsText}
|
||||
disabled={!hasAnyLogs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { saveAs } from "file-saver";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { ChevronDownIcon, DownloadIcon, PackageIcon } from "lucide-react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorDetail } from "#/api/errors";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
|
||||
type DownloadableLogSet = {
|
||||
label: string;
|
||||
filenameSuffix: string;
|
||||
logsText: string;
|
||||
startIcon?: ReactNode;
|
||||
};
|
||||
|
||||
type DownloadSelectedAgentLogsButtonProps = {
|
||||
agentName: string;
|
||||
filenameSuffix: string;
|
||||
logsText: string;
|
||||
logSets: readonly DownloadableLogSet[];
|
||||
allLogsText: string;
|
||||
disabled?: boolean;
|
||||
download?: (file: Blob, filename: string) => void | Promise<void>;
|
||||
};
|
||||
@@ -17,13 +30,13 @@ export const DownloadSelectedAgentLogsButton: FC<
|
||||
DownloadSelectedAgentLogsButtonProps
|
||||
> = ({
|
||||
agentName,
|
||||
filenameSuffix,
|
||||
logsText,
|
||||
logSets,
|
||||
allLogsText,
|
||||
disabled = false,
|
||||
download = saveAs,
|
||||
}) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const handleDownload = async () => {
|
||||
const downloadLogs = async (logsText: string, filenameSuffix: string) => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
const file = new Blob([logsText], { type: "text/plain" });
|
||||
@@ -37,15 +50,40 @@ export const DownloadSelectedAgentLogsButton: FC<
|
||||
}
|
||||
};
|
||||
|
||||
const hasAllLogs = allLogsText.length > 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
disabled={disabled || isDownloading}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadIcon />
|
||||
{isDownloading ? "Downloading..." : "Download logs"}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="subtle" size="sm" disabled={disabled || isDownloading}>
|
||||
<DownloadIcon />
|
||||
{isDownloading ? "Downloading..." : "Download logs"}
|
||||
<ChevronDownIcon className="size-icon-sm" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={!hasAllLogs}
|
||||
onSelect={() => {
|
||||
downloadLogs(allLogsText, "all-logs");
|
||||
}}
|
||||
>
|
||||
<PackageIcon />
|
||||
Download all logs
|
||||
</DropdownMenuItem>
|
||||
{logSets.map((logSet) => (
|
||||
<DropdownMenuItem
|
||||
key={logSet.filenameSuffix}
|
||||
disabled={logSet.logsText.length === 0}
|
||||
onSelect={() => {
|
||||
downloadLogs(logSet.logsText, logSet.filenameSuffix);
|
||||
}}
|
||||
>
|
||||
{logSet.startIcon}
|
||||
<span>Download {logSet.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user