Skip to content

Commit

Permalink
feat: service logs full download (#1895)
Browse files Browse the repository at this point in the history
## Description:
This PR implements the full streamed download of service logs. This is
based on some work already done by @tedim52 in tedi/copydownload.

Changes:
* The copy logs button is no longer available on the Service Logs page
* The download logs button sends the log stream to a stream writer to
download them as a file.

Concerns:
* For services that log excessively, like the log spammer, it seems
downloading the logs is incredibly slow - <1kb/s. I'm not sure what
could improve this, but if the backend streamed more than one record per
message (as seems to be possible by the api spec) that seems like it
would help (and would help improve performance when rendering service
logs in the ui).

### Demo


https://github.com/kurtosis-tech/kurtosis/assets/4419574/61716034-fb6e-47c1-b776-e8c262b40799

## Is this change user facing?
YES

---------

Co-authored-by: Anders Schwartz <adschwartz@users.noreply.github.com>
  • Loading branch information
Dartoxian and adschwartz committed Dec 7, 2023
1 parent c3911f6 commit b91333f
Show file tree
Hide file tree
Showing 16 changed files with 156 additions and 115 deletions.
18 changes: 15 additions & 3 deletions enclave-manager/web/src/components/DownloadButton.tsx
@@ -1,10 +1,11 @@
import { Button, ButtonProps, IconButton, IconButtonProps } from "@chakra-ui/react";
import { FiDownload } from "react-icons/fi";
import { isDefined } from "../utils";
import streamsaver from "streamsaver";
import { isAsyncIterable, isDefined, stripAnsi } from "../utils";
import { saveTextAsFile } from "../utils/download";

type DownloadButtonProps<IsIconButton extends boolean> = (IsIconButton extends true ? IconButtonProps : ButtonProps) & {
valueToDownload?: (() => string) | string | null;
valueToDownload?: (() => string) | (() => AsyncIterable<string>) | string | null;
fileName: string;
text?: IsIconButton extends true ? string : never;
isIconButton?: IsIconButton;
Expand All @@ -17,9 +18,20 @@ export const DownloadButton = <IsIconButton extends boolean>({
isIconButton,
...buttonProps
}: DownloadButtonProps<IsIconButton>) => {
const handleDownloadClick = () => {
const handleDownloadClick = async () => {
if (isDefined(valueToDownload)) {
const v = typeof valueToDownload === "string" ? valueToDownload : valueToDownload();

if (isAsyncIterable(v)) {
const writableStream = streamsaver.createWriteStream(fileName);
const writer = writableStream.getWriter();

for await (const part of v) {
await writer.write(new TextEncoder().encode(`${stripAnsi(part)}\n`));
}
await writer.close();
return;
}
saveTextAsFile(v, fileName);
}
};
Expand Down
Expand Up @@ -120,7 +120,7 @@ const theme = extendTheme({
const outline = theme.components.Button.variants!.outline(props);
return {
...outline,
_hover: { ...outline._hover, bg: "gray.700" },
_hover: { ...outline._hover, bg: "gray.600" },
color: `${props.colorScheme}.400`,
borderColor: "gray.300",
};
Expand Down
19 changes: 2 additions & 17 deletions enclave-manager/web/src/components/PackageSourceButton.tsx
Expand Up @@ -3,16 +3,14 @@ import { PropsWithChildren } from "react";
import { IoLogoGithub } from "react-icons/io";
import { useKurtosisPackageIndexerClient } from "../client/packageIndexer/KurtosisPackageIndexerClientContext";
import { isDefined, wrapResult } from "../utils";
import { CopyButton } from "./CopyButton";

type EnclaveSourceProps = PropsWithChildren<
ButtonProps & {
source: "loading" | string | null;
hideCopy?: boolean;
}
>;

export const PackageSourceButton = ({ source, hideCopy, children, ...buttonProps }: EnclaveSourceProps) => {
export const PackageSourceButton = ({ source, children, ...buttonProps }: EnclaveSourceProps) => {
const kurtosisIndexer = useKurtosisPackageIndexerClient();

if (!isDefined(source)) {
Expand Down Expand Up @@ -63,18 +61,5 @@ export const PackageSourceButton = ({ source, hideCopy, children, ...buttonProps
}
}

return (
<ButtonGroup>
{button}
{!hideCopy && (
<CopyButton
contentName={"package id"}
valueToCopy={source}
isIconButton
aria-label={"Copy package id"}
size={buttonProps.size || "xs"}
/>
)}
</ButtonGroup>
);
return <ButtonGroup>{button}</ButtonGroup>;
};
37 changes: 24 additions & 13 deletions enclave-manager/web/src/components/catalog/KurtosisPackageCard.tsx
@@ -1,4 +1,4 @@
import { Flex, Icon, Image, Text } from "@chakra-ui/react";
import { Box, Flex, Icon, Image, Text } from "@chakra-ui/react";
import { IoStar } from "react-icons/io5";
import { Link } from "react-router-dom";
import { useKurtosisClient } from "../../client/enclaveManager/KurtosisClientContext";
Expand Down Expand Up @@ -41,19 +41,30 @@ export const KurtosisPackageCard = ({ kurtosisPackage }: KurtosisPackageCardProp
<Text noOfLines={2} fontSize={"lg"}>
{readablePackageName(kurtosisPackage.name)}
</Text>
<Flex justifyContent={"space-between"} fontSize={"xs"}>
<Text as={"span"} textTransform={"capitalize"}>
{kurtosisPackage.repositoryMetadata?.owner.replaceAll("-", " ") || "Unknown owner"}
</Text>
<Flex gap={"4px"} alignItems={"center"}>
{kurtosisPackage.stars > 0 && (
<>
<Icon color="gray.500" as={IoStar} />
<Text as={"span"}>{kurtosisPackage.stars.toString()}</Text>
</>
)}
<Box
flex={"1"}
sx={{
containerType: "size",
containerName: "details-container",
"@container details-container (min-height: 30px)": {
"> div": { flexDirection: "column", justifyContent: "flex-end", height: "100%" },
},
}}
>
<Flex justifyContent={"space-between"} fontSize={"xs"} gap={"8px"}>
<Text as={"span"} textTransform={"capitalize"}>
{kurtosisPackage.repositoryMetadata?.owner.replaceAll("-", " ") || "Unknown owner"}
</Text>
<Flex gap={"4px"} alignItems={"center"}>
{kurtosisPackage.stars > 0 && (
<>
<Icon color="gray.500" as={IoStar} />
<Text as={"span"}>{kurtosisPackage.stars.toString()}</Text>
</>
)}
</Flex>
</Flex>
</Flex>
</Box>
</Flex>
</Flex>
<Flex gap={"16px"} width={"100%"}>
Expand Down
83 changes: 44 additions & 39 deletions enclave-manager/web/src/components/enclaves/logs/LogViewer.tsx
Expand Up @@ -19,7 +19,7 @@ import {
Text,
Tooltip,
} from "@chakra-ui/react";
import { debounce, throttle } from "lodash";
import { throttle } from "lodash";
import { ChangeEvent, MutableRefObject, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FiSearch } from "react-icons/fi";
import { MdArrowBackIosNew, MdArrowForwardIos } from "react-icons/md";
Expand All @@ -39,6 +39,8 @@ type LogViewerProps = {
ProgressWidget?: ReactElement;
logsFileName?: string;
searchEnabled?: boolean;
copyLogsEnabled?: boolean;
onGetAllLogs?: () => AsyncIterable<string>;
};

type SearchBaseState = {
Expand Down Expand Up @@ -69,6 +71,8 @@ export const LogViewer = ({
ProgressWidget,
logsFileName,
searchEnabled,
copyLogsEnabled,
onGetAllLogs,
}: LogViewerProps) => {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [logLines, setLogLines] = useState(propsLogLines);
Expand All @@ -94,7 +98,7 @@ export const LogViewer = ({
}
};

const handleSearchStateChange = (updater: ((prevState: SearchState) => SearchState) | SearchState) => {
const handleSearchStateChange = useCallback((updater: ((prevState: SearchState) => SearchState) | SearchState) => {
setSearchState((prevState) => {
const newState = typeof updater === "object" ? updater : updater(prevState);
if (
Expand All @@ -106,7 +110,7 @@ export const LogViewer = ({
}
return newState;
});
};
}, []);

const getLogsValue = () => {
return logLines
Expand Down Expand Up @@ -179,17 +183,19 @@ export const LogViewer = ({
</FormLabel>
</FormControl>
<ButtonGroup>
<CopyButton
contentName={"logs"}
valueToCopy={getLogsValue}
size={"sm"}
isDisabled={logLines.length === 0}
isIconButton
aria-label={"Copy logs"}
color={"gray.100"}
/>
{copyLogsEnabled && (
<CopyButton
contentName={"logs"}
valueToCopy={getLogsValue}
size={"sm"}
isDisabled={logLines.length === 0}
isIconButton
aria-label={"Copy logs"}
color={"gray.100"}
/>
)}
<DownloadButton
valueToDownload={getLogsValue}
valueToDownload={onGetAllLogs || getLogsValue}
size={"sm"}
fileName={logsFileName || `logs.txt`}
isDisabled={logLines.length === 0}
Expand All @@ -213,6 +219,8 @@ const SearchControls = ({ searchState, onChangeSearchState, logLines }: SearchCo
const searchRef: MutableRefObject<HTMLInputElement | null> = useRef(null);
const [showSearchForm, setShowSearchForm] = useState(false);

const maybeCurrentSearchIndex = searchState.type === "success" ? searchState.currentSearchIndex : null;

const updateMatches = useCallback(
(searchTerm: string) => {
if (isNotEmpty(searchTerm)) {
Expand Down Expand Up @@ -247,44 +255,38 @@ const SearchControls = ({ searchState, onChangeSearchState, logLines }: SearchCo
[logLines, onChangeSearchState],
);

const debouncedUpdateMatches = useMemo(() => debounce(updateMatches, 100), [updateMatches]);
const throttledUpdateMatches = useMemo(() => throttle(updateMatches, 300), [updateMatches]);

const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
onChangeSearchState((state) => ({ ...state, rawSearchTerm: e.target.value }));
debouncedUpdateMatches(e.target.value);
throttledUpdateMatches(e.target.value);
};

const updateSearchIndexBounded = useCallback(
(newIndex: number) => {
if (searchState.type !== "success") {
return;
}
if (newIndex > searchState.searchMatchesIndices.length - 1) {
newIndex = 0;
}
if (newIndex < 0) {
newIndex = searchState.searchMatchesIndices.length - 1;
}
onChangeSearchState((state) => ({ ...state, currentSearchIndex: newIndex }));
onChangeSearchState((searchState) => {
if (searchState.type !== "success" || searchState.searchMatchesIndices.length === 0) {
return searchState;
}
if (newIndex > searchState.searchMatchesIndices.length - 1) {
newIndex = 0;
}
if (newIndex < 0) {
newIndex = searchState.searchMatchesIndices.length - 1;
}
return { ...searchState, currentSearchIndex: newIndex };
});
},
[onChangeSearchState, searchState],
[onChangeSearchState],
);

const handlePriorMatchClick = useCallback(() => {
updateSearchIndexBounded(
searchState.type === "success" && isDefined(searchState.currentSearchIndex)
? searchState.currentSearchIndex - 1
: 0,
);
}, [updateSearchIndexBounded, searchState]);
updateSearchIndexBounded(isDefined(maybeCurrentSearchIndex) ? maybeCurrentSearchIndex - 1 : 0);
}, [updateSearchIndexBounded, maybeCurrentSearchIndex]);

const handleNextMatchClick = useCallback(() => {
updateSearchIndexBounded(
searchState.type === "success" && isDefined(searchState.currentSearchIndex)
? searchState.currentSearchIndex + 1
: 0,
);
}, [updateSearchIndexBounded, searchState]);
updateSearchIndexBounded(isDefined(maybeCurrentSearchIndex) ? maybeCurrentSearchIndex + 1 : 0);
}, [updateSearchIndexBounded, maybeCurrentSearchIndex]);

const handleClearSearch = useCallback(() => {
onChangeSearchState({ type: "init", rawSearchTerm: "" });
Expand Down Expand Up @@ -313,9 +315,12 @@ const SearchControls = ({ searchState, onChangeSearchState, logLines }: SearchCo
searchRef.current.focus();
}
},
next: () => {
enter: () => {
handleNextMatchClick();
},
"shift-enter": () => {
handlePriorMatchClick();
},
escape: () => {
if (isDefined(searchRef.current) && searchRef.current === document.activeElement) {
handleClearSearch();
Expand Down
@@ -1,4 +1,4 @@
import { Button, Checkbox } from "@chakra-ui/react";
import { Button, Checkbox, Text } from "@chakra-ui/react";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import { FilesArtifactNameAndUuid, ServiceInfo } from "enclave-manager-sdk/build/api_container_service_pb";
import { EnclaveContainersStatus } from "enclave-manager-sdk/build/engine_service_pb";
Expand Down Expand Up @@ -93,7 +93,9 @@ export const EnclavesTable = ({ enclavesData, selection, onSelectionChange }: En
cell: (nameCell) => (
<Link to={`/enclave/${nameCell.row.original.uuid}/overview`}>
<Button size={"sm"} variant={"ghost"}>
{nameCell.row.original.name}
<Text as={"span"} maxW={"200px"} textOverflow={"ellipsis"} overflow={"hidden"}>
{nameCell.row.original.name}
</Text>
</Button>
</Link>
),
Expand Down
Expand Up @@ -16,7 +16,6 @@ export const DownloadFileArtifactButton = ({ file, enclave }: DownloadFileButton

const handleDownloadClick = async () => {
setIsLoading(true);
// todo: get tgz download instead
const fileParts = await kurtosisClient.downloadFilesArtifact(enclave, file);
const writableStream = streamsaver.createWriteStream(`${enclave.name}--${file.fileName}.tgz`);
const writer = writableStream.getWriter();
Expand Down
45 changes: 28 additions & 17 deletions enclave-manager/web/src/components/useKeyboardAction.ts
@@ -1,33 +1,44 @@
import { useEffect } from "react";
import { isDefined } from "../utils";

export type KeyboardActions = "escape" | "find" | "omniFind" | "next";
export type KeyboardActions = "escape" | "find" | "omniFind" | "enter" | "shift-enter";

export type OnCtrlPressHandlers = Partial<Record<KeyboardActions, () => void>>;

const eventIsType = (e: KeyboardEvent, type: KeyboardActions) => {
const getEventType = (e: KeyboardEvent): KeyboardActions | null => {
const ctrlOrMeta = e.ctrlKey || e.metaKey;

switch (type) {
case "find":
return ctrlOrMeta && e.keyCode === 70; // F
case "next":
return ctrlOrMeta && e.keyCode === 71; // G
case "omniFind":
return ctrlOrMeta && e.keyCode === 75; // K
case "escape":
return e.key === "Escape" || e.keyCode === 27;
if (ctrlOrMeta && e.keyCode === 70) {
// F
return "find";
}
if (e.shiftKey && e.keyCode === 13) {
// shift + enter
return "shift-enter";
}
if (e.keyCode === 13) {
// enter
return "enter";
}
if (ctrlOrMeta && e.keyCode === 75) {
// K
return "omniFind";
}
if (e.key === "Escape" || e.keyCode === 27) {
return "escape";
}
return null;
};

export const useKeyboardAction = (handlers: OnCtrlPressHandlers) => {
useEffect(() => {
const listener = function (e: KeyboardEvent) {
for (const [handlerType, handler] of Object.entries(handlers)) {
if (eventIsType(e, handlerType as KeyboardActions)) {
e.preventDefault();
handler();
return;
}
const eventType = getEventType(e);
const handler = isDefined(eventType) ? handlers[eventType] : null;
if (isDefined(handler)) {
e.preventDefault();
handler();
return;
}
};
window.addEventListener("keydown", listener);
Expand Down

0 comments on commit b91333f

Please sign in to comment.