Skip to content

Commit

Permalink
fix: emui optimistic data loading (#1771)
Browse files Browse the repository at this point in the history
## Description:
This PR removes the use of react-router data loaders and actions,
instead managing this behaviour inside a new `EMUIAppContext`. This
allows us to implement optimistic ui patterns - letting us load the
initial enclave list as data become available, and updating it without a
backend fetch (on create/delete operations).

Also this PR fixes the issue identified with boolean values not being
persisted - in fact the values were read and persisted, just not
rendered correctly on subsequent load.

### Demo

As this is quite a substantial change, this demo quickly goes over the
majority of the data interactions in the emui.


https://github.com/kurtosis-tech/kurtosis/assets/4419574/0a7565bd-4e3c-43f4-bc10-8cb6b71eb3a3

## Is this change user facing?
YES
  • Loading branch information
Dartoxian committed Nov 13, 2023
1 parent 35bad59 commit f105fa0
Show file tree
Hide file tree
Showing 37 changed files with 787 additions and 388 deletions.
10 changes: 6 additions & 4 deletions enclave-manager/web/package.json
Expand Up @@ -12,8 +12,11 @@
"@emotion/styled": "^11.11.0",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-table": "^8.10.7",
"ansi-to-html": "^0.7.2",
"enclave-manager-sdk": "file:../api/typescript",
"framer-motion": "^10.16.4",
"has-ansi": "^5.0.1",
"html-react-parser": "^4.2.2",
"lodash": "^4.17.21",
"luxon": "^3.4.3",
"react": "^18.2.0",
Expand All @@ -25,10 +28,7 @@
"react-scripts": "5.0.1",
"react-virtuoso": "^4.6.2",
"streamsaver": "^2.0.6",
"true-myth": "^7.1.0",
"ansi-to-html": "^0.7.2",
"has-ansi": "^5.0.1",
"html-react-parser": "^4.2.2"
"true-myth": "^7.1.0"
},
"devDependencies": {
"@types/luxon": "^3.3.3",
Expand All @@ -39,6 +39,7 @@
"monaco-editor": "^0.44.0",
"prettier": "3.0.3",
"prettier-plugin-organize-imports": "^3.2.3",
"serve": "^14.2.1",
"source-map-explorer": "^2.5.3",
"typescript": "^4.4.2"
},
Expand All @@ -48,6 +49,7 @@
"clean": "rm -rf build",
"cleanInstall": "rm -rf node_modules; yarn install",
"start": "react-scripts start",
"start:prod": "serve -s build",
"build": "react-scripts build",
"postbuild": "cp -r build/ ../../engine/server/webapp",
"prettier": "prettier . --check",
Expand Down
Expand Up @@ -36,6 +36,7 @@ export class KurtosisPackageIndexerClient {
baseUrl: "github.com",
owner: components[1],
name: components[2],
rootPath: components.filter((v, i) => i > 2 && v.length > 0).join("/") + "/",
},
}),
);
Expand Down
21 changes: 14 additions & 7 deletions enclave-manager/web/src/components/KurtosisBreadcrumbs.tsx
Expand Up @@ -2,19 +2,23 @@ import { ChevronRightIcon } from "@chakra-ui/icons";
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, Button, Flex } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { Link, Params, UIMatch, useMatches } from "react-router-dom";
import { EmuiAppState, useEmuiAppContext } from "../emui/EmuiAppContext";
import { isDefined } from "../utils";
import { RemoveFunctions } from "../utils/types";

export type KurtosisBreadcrumb = {
name: string;
destination: string;
};

export const KurtosisBreadcrumbs = () => {
const { enclaves, filesAndArtifactsByEnclave, starlarkRunsByEnclave, servicesByEnclave } = useEmuiAppContext();

const matches = useMatches() as UIMatch<
object,
{
crumb?: (
data: Record<string, object>,
state: RemoveFunctions<EmuiAppState>,
params: Params<string>,
) => KurtosisBreadcrumb | Promise<KurtosisBreadcrumb>;
}
Expand All @@ -24,21 +28,24 @@ export const KurtosisBreadcrumbs = () => {

useEffect(() => {
(async () => {
const allLoaderData = matches
.filter((match) => isDefined(match.data))
.reduce((acc, match) => ({ ...acc, [match.id]: match.data }), {});

setMatchCrumbs(
await Promise.all(
matches
.map((match) =>
isDefined(match.handle?.crumb) ? Promise.resolve(match.handle.crumb(allLoaderData, match.params)) : null,
isDefined(match.handle?.crumb)
? Promise.resolve(
match.handle.crumb(
{ enclaves, filesAndArtifactsByEnclave, starlarkRunsByEnclave, servicesByEnclave },
match.params,
),
)
: null,
)
.filter(isDefined),
),
);
})();
}, [matches]);
}, [matches, enclaves, filesAndArtifactsByEnclave, starlarkRunsByEnclave, servicesByEnclave]);

return (
<Flex h="40px" p={"4px 0"} alignItems={"center"}>
Expand Down
Expand Up @@ -20,6 +20,14 @@ export const EditEnclaveButton = ({ enclave }: EditEnclaveButtonProps) => {
setKurtosisPackage(kurtosisPackage);
};

if (!isDefined(enclave.starlarkRun)) {
return (
<Button isLoading={true} colorScheme={"blue"} leftIcon={<FiEdit2 />} size={"md"}>
Edit
</Button>
);
}

if (enclave.starlarkRun.isErr) {
return (
<Tooltip label={"Cannot find previous run config to edit"}>
Expand Down
Expand Up @@ -58,7 +58,7 @@ export const EnclaveConfigurationForm = forwardRef<
case ArgumentValueType.LIST:
return value.map((v: any) => transformValue(innerValuetype, v));
case ArgumentValueType.BOOL:
return isStringTrue(value);
return isDefined(value) ? isStringTrue(value) : null;
case ArgumentValueType.INTEGER:
return isNaN(value) || isNaN(parseFloat(value)) ? null : parseFloat(value);
case ArgumentValueType.STRING:
Expand Down
Expand Up @@ -7,7 +7,9 @@ type BooleanArgumentInputProps = Omit<KurtosisArgumentTypeInputProps, "type"> &
};

export const BooleanArgumentInput = ({ inputType, ...props }: BooleanArgumentInputProps) => {
const { register } = useEnclaveConfigurationFormContext();
const { register, getValues } = useEnclaveConfigurationFormContext();

const currentDefault = getValues(props.name);

if (inputType === "switch") {
return (
Expand All @@ -22,7 +24,7 @@ export const BooleanArgumentInput = ({ inputType, ...props }: BooleanArgumentInp
);
} else {
return (
<RadioGroup>
<RadioGroup defaultValue={currentDefault}>
<Stack direction={"row"}>
<Radio
{...register(props.name, {
Expand Down
Expand Up @@ -40,6 +40,7 @@ export const DictArgumentInput = ({ keyType, valueType, ...otherProps }: DictArg
<ButtonGroup isAttached>
<CopyButton
contentName={"value"}
size={"sm"}
valueToCopy={() =>
JSON.stringify(
getValues(otherProps.name).reduce(
Expand All @@ -49,7 +50,7 @@ export const DictArgumentInput = ({ keyType, valueType, ...otherProps }: DictArg
)
}
/>
<PasteButton onValuePasted={handleValuePaste} />
<PasteButton size="sm" onValuePasted={handleValuePaste} />
</ButtonGroup>
{fields.map((field, i) => (
<Flex key={i} gap={"10px"}>
Expand All @@ -63,7 +64,7 @@ export const DictArgumentInput = ({ keyType, valueType, ...otherProps }: DictArg
name={`${otherProps.name as `args.${string}`}.${i}.key`}
validate={otherProps.validate}
isRequired
size={"xs"}
size={"sm"}
width={"222px"}
/>
</KurtosisArgumentSubtypeFormControl>
Expand All @@ -77,17 +78,17 @@ export const DictArgumentInput = ({ keyType, valueType, ...otherProps }: DictArg
name={`${otherProps.name as `args.${string}`}.${i}.value`}
validate={otherProps.validate}
isRequired
size={"xs"}
size={"sm"}
width={"222px"}
/>
</KurtosisArgumentSubtypeFormControl>
<Button onClick={() => remove(i)} leftIcon={<FiDelete />} size={"xs"} colorScheme={"red"}>
<Button onClick={() => remove(i)} leftIcon={<FiDelete />} size={"sm"} colorScheme={"red"}>
Delete
</Button>
</Flex>
))}
<Flex>
<Button onClick={() => append({})} leftIcon={<FiPlus />} size={"xs"} colorScheme={"kurtosisGreen"}>
<Button onClick={() => append({})} leftIcon={<FiPlus />} size={"sm"} colorScheme={"kurtosisGreen"}>
Add
</Button>
</Flex>
Expand Down
Expand Up @@ -38,6 +38,7 @@ export const ListArgumentInput = ({ valueType, ...otherProps }: ListArgumentInpu
<Flex flexDirection={"column"} gap={"10px"}>
<ButtonGroup isAttached>
<CopyButton
size={"sm"}
contentName={"value"}
valueToCopy={() => JSON.stringify(getValues(otherProps.name).map(({ value }: { value: any }) => value))}
/>
Expand All @@ -56,16 +57,16 @@ export const ListArgumentInput = ({ valueType, ...otherProps }: ListArgumentInpu
isRequired
validate={otherProps.validate}
width={"411px"}
size={"xs"}
size={"sm"}
/>
</KurtosisArgumentSubtypeFormControl>
<Button onClick={() => remove(i)} leftIcon={<FiDelete />} size={"xs"} colorScheme={"red"}>
<Button onClick={() => remove(i)} leftIcon={<FiDelete />} size={"sm"} colorScheme={"red"}>
Delete
</Button>
</Flex>
))}
<Flex>
<Button onClick={() => append({ value: "" })} leftIcon={<FiPlus />} colorScheme={"kurtosisGreen"} size={"xs"}>
<Button onClick={() => append({ value: "" })} leftIcon={<FiPlus />} colorScheme={"kurtosisGreen"} size={"sm"}>
Add
</Button>
</Flex>
Expand Down
Expand Up @@ -15,9 +15,10 @@ import {
import { EnclaveMode } from "enclave-manager-sdk/build/engine_service_pb";
import { useMemo, useRef, useState } from "react";
import { SubmitHandler } from "react-hook-form";
import { useNavigate, useSubmit } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useKurtosisClient } from "../../../client/enclaveManager/KurtosisClientContext";
import { ArgumentValueType, KurtosisPackage } from "../../../client/packageIndexer/api/kurtosis_package_indexer_pb";
import { useEmuiAppContext } from "../../../emui/EmuiAppContext";
import { EnclaveFullInfo } from "../../../emui/enclaves/types";
import { assertDefined, isDefined, stringifyError } from "../../../utils";
import { KURTOSIS_PACKAGE_ID_URL_ARG, KURTOSIS_PACKAGE_PARAMS_URL_ARG } from "../../constants";
Expand Down Expand Up @@ -49,14 +50,14 @@ export const ConfigureEnclaveModal = ({
existingEnclave,
}: ConfigureEnclaveModalProps) => {
const kurtosisClient = useKurtosisClient();
const { createEnclave, runStarlarkPackage } = useEmuiAppContext();
const navigator = useNavigate();
const submit = useSubmit();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
const formRef = useRef<EnclaveConfigurationFormImperativeAttributes>(null);

const initialValues = useMemo(() => {
if (isDefined(existingEnclave)) {
if (isDefined(existingEnclave) && isDefined(existingEnclave.starlarkRun)) {
if (existingEnclave.starlarkRun.isErr) {
setError(
`Could not retrieve starlark run for previous configuration, got error: ${existingEnclave.starlarkRun.isErr}`,
Expand All @@ -73,7 +74,7 @@ export const ConfigureEnclaveModal = ({
): any => {
switch (argType) {
case ArgumentValueType.BOOL:
return !!value ? "true" : "false";
return !!value ? "true" : isDefined(value) ? "false" : "";
case ArgumentValueType.INTEGER:
return isDefined(value) ? `${value}` : "";
case ArgumentValueType.STRING:
Expand Down Expand Up @@ -158,7 +159,7 @@ export const ConfigureEnclaveModal = ({
let enclaveUUID = existingEnclave?.shortenedUuid;
if (!isDefined(existingEnclave)) {
setIsLoading(true);
const newEnclave = await kurtosisClient.createEnclave(formData.enclaveName, "info", formData.restartServices);
const newEnclave = await createEnclave(formData.enclaveName, "info", formData.restartServices);
setIsLoading(false);

if (newEnclave.isErr) {
Expand All @@ -177,14 +178,9 @@ export const ConfigureEnclaveModal = ({
setError(`Cannot trigger starlark run as apic info cannot be found`);
return;
}
submit(
{ config: formData, packageId: kurtosisPackage.name, apicInfo: apicInfo.toJson() },
{
method: "post",
action: `/enclave/${enclaveUUID}/logs`,
encType: "application/json",
},
);

const logsIterator = await runStarlarkPackage(apicInfo, kurtosisPackage.name, formData.args);
navigator(`/enclave/${enclaveUUID}/logs`, { state: { logs: logsIterator } });
onClose();
};

Expand Down
Expand Up @@ -6,6 +6,7 @@ import { DateTime } from "luxon";
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { EnclaveFullInfo } from "../../../emui/enclaves/types";
import { isDefined } from "../../../utils";
import { DataTable } from "../../DataTable";
import { FormatDateTime } from "../../FormatDateTime";
import { EnclaveArtifactsSummary } from "../widgets/EnclaveArtifactsSummary";
Expand All @@ -18,9 +19,9 @@ type EnclaveTableRow = {
name: string;
status: EnclaveContainersStatus;
created: DateTime | null;
source: string | null;
services: ServiceInfo[] | null;
artifacts: FilesArtifactNameAndUuid[] | null;
source: "loading" | string | null;
services: "loading" | ServiceInfo[] | null;
artifacts: "loading" | FilesArtifactNameAndUuid[] | null;
};

const enclaveToRow = (enclave: EnclaveFullInfo): EnclaveTableRow => {
Expand All @@ -29,9 +30,21 @@ const enclaveToRow = (enclave: EnclaveFullInfo): EnclaveTableRow => {
name: enclave.name,
status: enclave.containersStatus,
created: enclave.creationTime ? DateTime.fromJSDate(enclave.creationTime.toDate()) : null,
source: enclave.starlarkRun.isOk ? enclave.starlarkRun.value.packageId : null,
services: enclave.services.isOk ? Object.values(enclave.services.value.serviceInfo) : null,
artifacts: enclave.filesAndArtifacts.isOk ? enclave.filesAndArtifacts.value.fileNamesAndUuids : null,
source: !isDefined(enclave.starlarkRun)
? "loading"
: enclave.starlarkRun.isOk
? enclave.starlarkRun.value.packageId
: null,
services: !isDefined(enclave.services)
? "loading"
: enclave.services.isOk
? Object.values(enclave.services.value.serviceInfo)
: null,
artifacts: !isDefined(enclave.filesAndArtifacts)
? "loading"
: enclave.filesAndArtifacts.isOk
? enclave.filesAndArtifacts.value.fileNamesAndUuids
: null,
};
};

Expand Down
@@ -1,7 +1,8 @@
import { Button } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { FiTrash2 } from "react-icons/fi";
import { useFetcher } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useEmuiAppContext } from "../../../emui/EmuiAppContext";
import { EnclaveFullInfo } from "../../../emui/enclaves/types";
import { KurtosisAlertModal } from "../../KurtosisAlertModal";

Expand All @@ -10,7 +11,8 @@ type DeleteEnclavesButtonProps = {
};

export const DeleteEnclavesButton = ({ enclaves }: DeleteEnclavesButtonProps) => {
const fetcher = useFetcher();
const { destroyEnclave } = useEmuiAppContext();
const navigator = useNavigate();

const [showModal, setShowModal] = useState(false);
const [isLoading, setIsLoading] = useState(false);
Expand All @@ -28,13 +30,12 @@ export const DeleteEnclavesButton = ({ enclaves }: DeleteEnclavesButtonProps) =>

const handleDelete = async () => {
setIsLoading(true);
fetcher.submit(
{
intent: "delete",
enclaveUUIDs: enclaves.map(({ enclaveUuid }) => enclaveUuid),
},
{ method: "post", action: "/enclaves", encType: "application/json" },
);
for (const enclaveUUID of enclaves.map(({ enclaveUuid }) => enclaveUuid)) {
await destroyEnclave(enclaveUUID);
}
navigator("/enclaves");
setIsLoading(false);
setShowModal(false);
};

return (
Expand Down
@@ -1,16 +1,20 @@
import { Flex, Tag, TagProps, Tooltip } from "@chakra-ui/react";
import { Flex, Spinner, Tag, TagProps, Tooltip } from "@chakra-ui/react";
import { ServiceInfo, ServiceStatus } from "enclave-manager-sdk/build/api_container_service_pb";
import { isDefined } from "../../../utils";

type ServicesSummaryProps = {
services: ServiceInfo[] | null;
services: "loading" | ServiceInfo[] | null;
};

export const EnclaveServicesSummary = ({ services }: ServicesSummaryProps) => {
if (!isDefined(services)) {
return <Tag>Unknown</Tag>;
}

if (services === "loading") {
return <Spinner size={"xs"} />;
}

const runningServices = services.filter(({ serviceStatus }) => serviceStatus === ServiceStatus.RUNNING).length;
const stopppedServices = services.filter(({ serviceStatus }) => serviceStatus === ServiceStatus.STOPPED).length;
const unknownServices = services.filter(({ serviceStatus }) => serviceStatus === ServiceStatus.UNKNOWN).length;
Expand Down

0 comments on commit f105fa0

Please sign in to comment.