Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: service logs full download #1895

Merged
merged 6 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions enclave-manager/web/src/components/DownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -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";
Dartoxian marked this conversation as resolved.
Show resolved Hide resolved
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);
Dartoxian marked this conversation as resolved.
Show resolved Hide resolved
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading