From 2fc19db0a3d3659256a7925249e43b1d8f827ab6 Mon Sep 17 00:00:00 2001 From: akuitybot <105087302+akuitybot@users.noreply.github.com> Date: Fri, 4 Oct 2024 11:39:48 -0700 Subject: [PATCH] chore(backport release-0.9): chore(ui): improve stability (#2651) Co-authored-by: Mayursinh Sarvaiya Co-authored-by: Kent Rancourt --- ui/package.json | 1 + ui/pnpm-lock.yaml | 20 ++++++++++++ ui/src/app.tsx | 2 ++ .../create-analysis-template-modal.tsx | 13 +++++++- .../project/list/create-project-modal.tsx | 32 +++++++++---------- .../pipelines/create-warehouse-modal.tsx | 4 ++- .../features/project/pipelines/pipelines.tsx | 15 +++++---- .../project/pipelines/utils/watcher.ts | 24 +++++++++++--- ui/src/features/stage/create-stage.tsx | 4 ++- .../utils/cache/analysis-templates.ts | 28 ++++++++++++++++ ui/src/features/utils/cache/index.ts | 9 ++++++ ui/src/features/utils/cache/project.ts | 31 ++++++++++++++++++ ui/src/utils/decode-raw-data.ts | 6 ++++ 13 files changed, 158 insertions(+), 31 deletions(-) create mode 100644 ui/src/features/utils/cache/analysis-templates.ts create mode 100644 ui/src/features/utils/cache/index.ts create mode 100644 ui/src/features/utils/cache/project.ts diff --git a/ui/package.json b/ui/package.json index c7ef7e95e..444fe635e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@eslint/compat": "^1.1.1", "@openapi-contrib/openapi-schema-to-json-schema": "^5.1.0", + "@tanstack/react-query-devtools": "^5.59.0", "@types/json-schema": "^7.0.15", "@types/node": "^22.7.1", "@types/react": "^18.3.3", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index e94ebe0dc..5a265ab1c 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@openapi-contrib/openapi-schema-to-json-schema': specifier: ^5.1.0 version: 5.1.0 + '@tanstack/react-query-devtools': + specifier: ^5.59.0 + version: 5.59.0(@tanstack/react-query@5.56.2(react@18.3.1))(react@18.3.1) '@types/json-schema': specifier: ^7.0.15 version: 7.0.15 @@ -790,6 +793,15 @@ packages: '@tanstack/query-core@5.56.2': resolution: {integrity: sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q==} + '@tanstack/query-devtools@5.58.0': + resolution: {integrity: sha512-iFdQEFXaYYxqgrv63ots+65FGI+tNp5ZS5PdMU1DWisxk3fez5HG3FyVlbUva+RdYS5hSLbxZ9aw3yEs97GNTw==} + + '@tanstack/react-query-devtools@5.59.0': + resolution: {integrity: sha512-Kz7577FQGU8qmJxROIT/aOwmkTcxfBqgTP6r1AIvuJxVMVHPkp8eQxWQ7BnfBsy/KTJHiV9vMtRVo1+R1tB3vg==} + peerDependencies: + '@tanstack/react-query': ^5.59.0 + react: ^18 || ^19 + '@tanstack/react-query@5.56.2': resolution: {integrity: sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg==} peerDependencies: @@ -3840,6 +3852,14 @@ snapshots: '@tanstack/query-core@5.56.2': {} + '@tanstack/query-devtools@5.58.0': {} + + '@tanstack/react-query-devtools@5.59.0(@tanstack/react-query@5.56.2(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/query-devtools': 5.58.0 + '@tanstack/react-query': 5.56.2(react@18.3.1) + react: 18.3.1 + '@tanstack/react-query@5.56.2(react@18.3.1)': dependencies: '@tanstack/query-core': 5.56.2 diff --git a/ui/src/app.tsx b/ui/src/app.tsx index e537d50d8..7db47d7e6 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -1,5 +1,6 @@ import { TransportProvider } from '@connectrpc/connect-query'; import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ConfigProvider } from 'antd'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; @@ -54,6 +55,7 @@ export const App = () => ( + ); diff --git a/ui/src/features/project/analysis-templates/create-analysis-template-modal.tsx b/ui/src/features/project/analysis-templates/create-analysis-template-modal.tsx index 25970e10e..97cd00079 100644 --- a/ui/src/features/project/analysis-templates/create-analysis-template-modal.tsx +++ b/ui/src/features/project/analysis-templates/create-analysis-template-modal.tsx @@ -6,10 +6,12 @@ import { useForm } from 'react-hook-form'; import YamlEditor from '@ui/features/common/code-editor/yaml-editor-lazy'; import { FieldContainer } from '@ui/features/common/form/field-container'; import { ModalProps } from '@ui/features/common/modal/use-modal'; +import { queryCache } from '@ui/features/utils/cache'; import { createResource, listAnalysisTemplates } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; +import { decodeUint8ArrayYamlManifestToJson } from '@ui/utils/decode-raw-data'; import { getAnalysisTemplateYAMLExample } from './utils/analysis-template-example'; @@ -21,7 +23,16 @@ export const CreateAnalysisTemplateModal = ({ visible, hide, namespace }: Props) const queryClient = useQueryClient(); const { mutateAsync, isPending } = useMutation(createResource, { - onSuccess: () => hide() + onSuccess: (response) => { + for (const result of response?.results || []) { + if (result?.result?.case === 'createdResourceManifest') { + queryCache.analysisTemplates.add(namespace || '', [ + decodeUint8ArrayYamlManifestToJson(result?.result?.value) + ]); + } + } + hide(); + } }); const { control, handleSubmit } = useForm({ diff --git a/ui/src/features/project/list/create-project-modal.tsx b/ui/src/features/project/list/create-project-modal.tsx index d588ffae3..f9c1be58c 100644 --- a/ui/src/features/project/list/create-project-modal.tsx +++ b/ui/src/features/project/list/create-project-modal.tsx @@ -1,6 +1,5 @@ -import { createConnectQueryKey, useMutation } from '@connectrpc/connect-query'; +import { useMutation } from '@connectrpc/connect-query'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useQueryClient } from '@tanstack/react-query'; import { Form, Input, Modal, Tabs } from 'antd'; import type { JSONSchema4 } from 'json-schema'; import React from 'react'; @@ -11,11 +10,10 @@ import { z } from 'zod'; import { YamlEditor } from '@ui/features/common/code-editor/yaml-editor'; import { FieldContainer } from '@ui/features/common/form/field-container'; import { ModalComponentProps } from '@ui/features/common/modal/modal-context'; +import { queryCache } from '@ui/features/utils/cache'; import schema from '@ui/gen/schema/projects.kargo.akuity.io_v1alpha1.json'; -import { - createResource, - listProjects -} from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; +import { createResource } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; +import { decodeUint8ArrayYamlManifestToJson } from '@ui/utils/decode-raw-data'; import { zodValidators } from '@ui/utils/validators'; import { projectYAMLExample } from './utils/project-yaml-example'; @@ -25,9 +23,15 @@ const formSchema = z.object({ }); export const CreateProjectModal = ({ visible, hide }: ModalComponentProps) => { - const queryClient = useQueryClient(); const { mutateAsync, isPending } = useMutation(createResource, { - onSuccess: () => hide() + onSuccess: (response) => { + for (const result of response?.results || []) { + if (result?.result?.case === 'createdResourceManifest') { + queryCache.project.add([decodeUint8ArrayYamlManifestToJson(result?.result?.value)]); + } + } + hide(); + } }); const { control, handleSubmit, watch, setValue } = useForm({ @@ -39,15 +43,9 @@ export const CreateProjectModal = ({ visible, hide }: ModalComponentProps) => { const onSubmit = handleSubmit(async (data) => { const textEncoder = new TextEncoder(); - await mutateAsync( - { - manifest: textEncoder.encode(data.value) - }, - { - onSuccess: () => - queryClient.invalidateQueries({ queryKey: createConnectQueryKey(listProjects) }) - } - ); + await mutateAsync({ + manifest: textEncoder.encode(data.value) + }); }); const yamlValue = watch('value'); diff --git a/ui/src/features/project/pipelines/create-warehouse-modal.tsx b/ui/src/features/project/pipelines/create-warehouse-modal.tsx index 87c292cb7..da9138e27 100644 --- a/ui/src/features/project/pipelines/create-warehouse-modal.tsx +++ b/ui/src/features/project/pipelines/create-warehouse-modal.tsx @@ -24,7 +24,9 @@ const formSchema = z.object({ export const CreateWarehouseModal = ({ visible, hide, project }: Props) => { const { mutateAsync, isPending } = useMutation(createResource, { - onSuccess: () => hide() + onSuccess: () => { + hide(); + } }); const { control, handleSubmit } = useForm({ diff --git a/ui/src/features/project/pipelines/pipelines.tsx b/ui/src/features/project/pipelines/pipelines.tsx index fe5c355ec..21996a05b 100644 --- a/ui/src/features/project/pipelines/pipelines.tsx +++ b/ui/src/features/project/pipelines/pipelines.tsx @@ -173,17 +173,20 @@ export const Pipelines = ({ const client = useQueryClient(); - React.useEffect(() => { - if (!data || !isVisible || !warehouseData || !name) { + useEffect(() => { + if (!name || !isVisible) { return; } const watcher = new Watcher(name, client); - watcher.watchStages(data.stages.slice()); - watcher.watchWarehouses(warehouseData?.warehouses || [], refetchFreightData); - return () => watcher.cancelWatch(); - }, [isLoading, isVisible, name]); + watcher.watchStages(); + watcher.watchWarehouses(refetchFreightData); + + return () => { + watcher.cancelWatch(); + }; + }, [name, client, isVisible]); const [nodes, connectors, box, sortedStages, stageColorMap, warehouseColorMap] = usePipelineGraph( name, diff --git a/ui/src/features/project/pipelines/utils/watcher.ts b/ui/src/features/project/pipelines/utils/watcher.ts index 898dbaed4..59e777b53 100644 --- a/ui/src/features/project/pipelines/utils/watcher.ts +++ b/ui/src/features/project/pipelines/utils/watcher.ts @@ -10,15 +10,17 @@ import { listWarehouses } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; import { KargoService } from '@ui/gen/service/v1alpha1/service_connect'; +import { ListStagesResponse, ListWarehousesResponse } from '@ui/gen/service/v1alpha1/service_pb'; import { Stage, Warehouse } from '@ui/gen/v1alpha1/generated_pb'; async function ProcessEvents( stream: AsyncIterable, - data: S[], + getData: () => S[], getter: (e: T) => S, callback: (item: S, data: S[]) => void ) { for await (const e of stream) { + let data = getData(); const index = data.findIndex((item) => item.metadata?.name === getter(e).metadata?.name); if (e.type === 'DELETED') { if (index !== -1) { @@ -53,7 +55,7 @@ export class Watcher { this.cancel.abort(); } - async watchStages(stages: Stage[]) { + async watchStages() { const stream = this.promiseClient.watchStages( { project: this.project }, { signal: this.cancel.signal } @@ -61,7 +63,13 @@ export class Watcher { ProcessEvents( stream, - stages, + () => { + const data = this.client.getQueryData( + createConnectQueryKey(listStages, { project: this.project }) + ); + + return (data as ListStagesResponse)?.stages || []; + }, (e) => e.stage as Stage, (stage, data) => { // update Stages list @@ -78,7 +86,7 @@ export class Watcher { ); } - async watchWarehouses(warehouses: Warehouse[], refreshHook: () => void) { + async watchWarehouses(refreshHook: () => void) { const stream = this.promiseClient.watchWarehouses( { project: this.project }, { signal: this.cancel.signal } @@ -87,7 +95,13 @@ export class Watcher { ProcessEvents( stream, - warehouses, + () => { + const data = this.client.getQueryData( + createConnectQueryKey(listWarehouses, { project: this.project }) + ); + + return (data as ListWarehousesResponse)?.warehouses || []; + }, (e) => e.warehouse as Warehouse, (warehouse, data) => { // refetch freight if necessary diff --git a/ui/src/features/stage/create-stage.tsx b/ui/src/features/stage/create-stage.tsx index e867d123e..97d99753a 100644 --- a/ui/src/features/stage/create-stage.tsx +++ b/ui/src/features/stage/create-stage.tsx @@ -84,7 +84,9 @@ export const CreateStage = ({ const [tab, setTab] = useState('wizard'); const { mutateAsync, isPending } = useMutation(createResource, { - onSuccess: () => close() + onSuccess: () => { + close(); + } }); const { control, handleSubmit, setValue } = useForm({ diff --git a/ui/src/features/utils/cache/analysis-templates.ts b/ui/src/features/utils/cache/analysis-templates.ts new file mode 100644 index 000000000..e755a9517 --- /dev/null +++ b/ui/src/features/utils/cache/analysis-templates.ts @@ -0,0 +1,28 @@ +import { createConnectQueryKey, createProtobufSafeUpdater } from '@connectrpc/connect-query'; + +import { queryClient } from '@ui/config/query-client'; +import { AnalysisTemplate } from '@ui/gen/rollouts/api/v1alpha1/generated_pb'; +import { listAnalysisTemplates } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; + +export default { + add: (project: string, templates: AnalysisTemplate[]) => { + queryClient.setQueriesData( + { + queryKey: createConnectQueryKey(listAnalysisTemplates, { project }), + exact: false + }, + createProtobufSafeUpdater(listAnalysisTemplates, (prev) => { + let newTemplates = [...(prev?.analysisTemplates || [])]; + + if (templates?.length > 0) { + newTemplates = newTemplates.concat(templates); + } + + return { + ...prev, + analysisTemplates: newTemplates + }; + }) + ); + } +}; diff --git a/ui/src/features/utils/cache/index.ts b/ui/src/features/utils/cache/index.ts new file mode 100644 index 000000000..c6bd19a82 --- /dev/null +++ b/ui/src/features/utils/cache/index.ts @@ -0,0 +1,9 @@ +// cache invalidation source-of-truth + +import analysisTemplates from './analysis-templates'; +import project from './project'; + +export const queryCache = { + project, + analysisTemplates +}; diff --git a/ui/src/features/utils/cache/project.ts b/ui/src/features/utils/cache/project.ts new file mode 100644 index 000000000..4f2004a94 --- /dev/null +++ b/ui/src/features/utils/cache/project.ts @@ -0,0 +1,31 @@ +import { createConnectQueryKey, createProtobufSafeUpdater } from '@connectrpc/connect-query'; + +import { queryClient } from '@ui/config/query-client'; +import { listProjects } from '@ui/gen/service/v1alpha1/service-KargoService_connectquery'; +import { Project } from '@ui/gen/v1alpha1/generated_pb'; + +export default { + add: (projects: Project[]) => { + queryClient.setQueriesData( + { + queryKey: createConnectQueryKey(listProjects) + // IMPORTANT: createConnectQueryKey returns falsy elements for filters so lets use only static identifiers + .slice(0, 2), + exact: false + }, + createProtobufSafeUpdater(listProjects, (prev) => { + let newProjects = [...(prev?.projects || [])]; + + if (projects?.length > 0) { + newProjects = newProjects.concat(projects); + } + + return { + ...prev, + total: newProjects.length, + projects: newProjects + }; + }) + ); + } +}; diff --git a/ui/src/utils/decode-raw-data.ts b/ui/src/utils/decode-raw-data.ts index 40e0a3246..58f47a589 100644 --- a/ui/src/utils/decode-raw-data.ts +++ b/ui/src/utils/decode-raw-data.ts @@ -1,3 +1,5 @@ +import yaml from 'yaml'; + type Data = { result: | { @@ -15,3 +17,7 @@ export const decodeRawData = (data?: Data) => new TextDecoder().decode( data?.result?.case === 'raw' ? (data?.result?.value ?? new Uint8Array()) : new Uint8Array() ); + +export const decodeUint8ArrayYamlManifestToJson = (raw: Uint8Array): T => { + return yaml.parse(new TextDecoder().decode(raw)); +};