From e7207732084a1df1564b6f330294335a8ecde394 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 19 Jul 2024 11:35:37 +0200 Subject: [PATCH] feat: app deployment UI (#5088) --- .../web/app/src/components/layouts/target.tsx | 16 + .../organization/members/invitations.tsx | 2 +- .../components/organization/members/list.tsx | 2 +- .../organization/members/migration.tsx | 2 +- .../components/organization/members/roles.tsx | 2 +- .../target/settings/cdn-access-tokens.tsx | 2 +- .../target/settings/schema-contracts.tsx | 2 +- .../src/components/ui/page-content-layout.tsx | 4 +- .../web/app/src/components/ui/skeleton.tsx | 7 + packages/web/app/src/lib/urql.ts | 9 + .../web/app/src/pages/target-app-version.tsx | 349 ++++++++++++++++++ packages/web/app/src/pages/target-apps.tsx | 326 ++++++++++++++++ .../web/app/src/pages/target-laboratory.tsx | 14 + .../web/app/src/pages/target-settings.tsx | 12 +- packages/web/app/src/router.tsx | 33 ++ packages/web/app/tailwind.config.cjs | 3 + 16 files changed, 771 insertions(+), 14 deletions(-) create mode 100644 packages/web/app/src/components/ui/skeleton.tsx create mode 100644 packages/web/app/src/pages/target-app-version.tsx create mode 100644 packages/web/app/src/pages/target-apps.tsx diff --git a/packages/web/app/src/components/layouts/target.tsx b/packages/web/app/src/components/layouts/target.tsx index 45303fcbb..da2e4abf1 100644 --- a/packages/web/app/src/components/layouts/target.tsx +++ b/packages/web/app/src/components/layouts/target.tsx @@ -22,6 +22,7 @@ export enum Page { History = 'history', Insights = 'insights', Laboratory = 'laboratory', + Apps = 'apps', Settings = 'settings', } @@ -36,6 +37,7 @@ const TargetLayoutQuery = graphql(` id cleanId name + isAppDeploymentsEnabled me { id ...CanAccessTarget_MemberFragment @@ -210,6 +212,20 @@ export const TargetLayout = ({ Insights + {currentOrganization.isAppDeploymentsEnabled && ( + + + Apps + + + )} diff --git a/packages/web/app/src/components/organization/members/roles.tsx b/packages/web/app/src/components/organization/members/roles.tsx index 87febede2..eededa523 100644 --- a/packages/web/app/src/components/organization/members/roles.tsx +++ b/packages/web/app/src/components/organization/members/roles.tsx @@ -896,7 +896,7 @@ export function OrganizationMemberRoles(props: { diff --git a/packages/web/app/src/components/target/settings/schema-contracts.tsx b/packages/web/app/src/components/target/settings/schema-contracts.tsx index 2b2fb9b54..866926f76 100644 --- a/packages/web/app/src/components/target/settings/schema-contracts.tsx +++ b/packages/web/app/src/components/target/settings/schema-contracts.tsx @@ -171,7 +171,7 @@ export function SchemaContracts(props: { <> diff --git a/packages/web/app/src/components/ui/page-content-layout.tsx b/packages/web/app/src/components/ui/page-content-layout.tsx index f5a0ddd5e..784b2f35f 100644 --- a/packages/web/app/src/components/ui/page-content-layout.tsx +++ b/packages/web/app/src/components/ui/page-content-layout.tsx @@ -51,14 +51,14 @@ SubPageLayout.displayName = 'SubPageLayout'; type SubPageLayoutHeaderProps = { children?: ReactNode; - title?: string; + subPageTitle?: ReactNode; description?: string | ReactNode; } & HTMLAttributes; const SubPageLayoutHeader = forwardRef((props, ref) => (
- {props.title} + {props.subPageTitle} {typeof props.description === 'string' ? ( {props.description} ) : ( diff --git a/packages/web/app/src/components/ui/skeleton.tsx b/packages/web/app/src/components/ui/skeleton.tsx new file mode 100644 index 000000000..8d623a059 --- /dev/null +++ b/packages/web/app/src/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from '@/lib/utils'; + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
; +} + +export { Skeleton }; diff --git a/packages/web/app/src/lib/urql.ts b/packages/web/app/src/lib/urql.ts index f14ce0705..c424b3b6a 100644 --- a/packages/web/app/src/lib/urql.ts +++ b/packages/web/app/src/lib/urql.ts @@ -5,6 +5,7 @@ import { env } from '@/env/frontend'; import schema from '@/gql/schema'; import { authExchange } from '@urql/exchange-auth'; import { cacheExchange } from '@urql/exchange-graphcache'; +import { relayPagination } from '@urql/exchange-graphcache/extras'; import { persistedExchange } from '@urql/exchange-persisted'; import { Mutation } from './urql-cache'; import { networkStatusExchange } from './urql-exchanges/state'; @@ -34,6 +35,14 @@ export const urqlClient = createClient({ updates: { Mutation, }, + resolvers: { + Target: { + appDeployments: relayPagination(), + }, + AppDeployment: { + documents: relayPagination(), + }, + }, keys: { RequestsOverTime: noKey, FailuresOverTime: noKey, diff --git a/packages/web/app/src/pages/target-app-version.tsx b/packages/web/app/src/pages/target-app-version.tsx new file mode 100644 index 000000000..ed5395364 --- /dev/null +++ b/packages/web/app/src/pages/target-app-version.tsx @@ -0,0 +1,349 @@ +import { useEffect, useState } from 'react'; +import ghost from '../../public/images/figures/ghost.svg?url'; +import { LoaderCircleIcon } from 'lucide-react'; +import { useClient, useQuery } from 'urql'; +import { Page, TargetLayout } from '@/components/layouts/target'; +import { Button } from '@/components/ui/button'; +import { CardDescription } from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { EmptyList } from '@/components/ui/empty-list'; +import { Meta } from '@/components/ui/meta'; +import { SubPageLayoutHeader } from '@/components/ui/page-content-layout'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Spinner } from '@/components/ui/spinner'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { graphql } from '@/gql'; +import { DotsHorizontalIcon } from '@radix-ui/react-icons'; +import { Link, useRouter } from '@tanstack/react-router'; + +const TargetAppsVersionQuery = graphql(` + query TargetAppsVersionQuery( + $organizationId: ID! + $projectId: ID! + $targetId: ID! + $appName: String! + $appVersion: String! + $first: Int + $after: String + ) { + organization(selector: { organization: $organizationId }) { + organization { + id + isAppDeploymentsEnabled + } + } + target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { + id + appDeployment(appName: $appName, appVersion: $appVersion) { + id + name + version + documents(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + hash + body + operationName + insightsHash + } + } + } + } + } + } +`); + +export function TargetAppVersionPage(props: { + organizationId: string; + projectId: string; + targetId: string; + appName: string; + appVersion: string; +}) { + const [data] = useQuery({ + query: TargetAppsVersionQuery, + variables: { + organizationId: props.organizationId, + projectId: props.projectId, + targetId: props.targetId, + appName: props.appName, + appVersion: props.appVersion, + first: 20, + after: null, + }, + }); + const router = useRouter(); + const client = useClient(); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const isAppDeploymentsEnabled = + !data.fetching && !data.stale && !data.data?.organization?.organization.isAppDeploymentsEnabled; + + useEffect(() => { + if (isAppDeploymentsEnabled) { + void router.navigate({ + to: '/$organizationId/$projectId/$targetId', + params: { + organizationId: props.organizationId, + projectId: props.projectId, + targetId: props.targetId, + }, + replace: true, + }); + } + }, [isAppDeploymentsEnabled]); + + const title = data.data?.target?.appDeployment + ? `${data.data.target.appDeployment.name}@${data.data.target.appDeployment.version}` + : 'App Deployment'; + + if (!data.fetching && !data.stale && !data?.data?.target?.appDeployment) { + return ( + <> + + +
+ Ghost illustration +

App Version not found.

+

This app does not seem to exist anymore.

+ +
+
+ + ); + } + + return ( + <> + + +
+ + + App Deployments + {' '} + /{' '} + {data.data?.target?.appDeployment ? ( + `${data.data.target.appDeployment.name}@${data.data.target.appDeployment.version}` + ) : ( + + )} + + } + description={ + <> + + Group your GraphQL operations by app version for app version statistics and + persisted operations. + + {/* + + Learn more about App Deployments + + */} + + } + /> +
+ {data.fetching || data.stale ? ( +
+
+ +
Loading app deployments
+
+
+ ) : !data.data?.target?.appDeployment?.documents?.edges.length ? ( + + ) : ( + <> +
+ + + + Document Hash + Operation Name + + Document Content + + + + + + {data.data?.target?.appDeployment.documents?.edges.map(edge => ( + + + + {edge.node.hash} + + + + {!edge.node.operationName ? ( + + + + anonymous + + +

The operation within the document has no name.

+
+
+
+ ) : ( + + {edge.node.operationName} + + )} +
+ + + {edge.node.body.length > 43 + ? edge.node.body.substring(0, 43).replace(/\n/g, '\\n') + '...' + : edge.node.body} + + + + + + + + + + + Open in Laboratory + + + + + Show Insights + + + + + +
+ ))} +
+
+
+
+ +
+ + )} +
+ + + ); +} diff --git a/packages/web/app/src/pages/target-apps.tsx b/packages/web/app/src/pages/target-apps.tsx new file mode 100644 index 000000000..5e1d9df90 --- /dev/null +++ b/packages/web/app/src/pages/target-apps.tsx @@ -0,0 +1,326 @@ +import { useEffect, useState } from 'react'; +import { format } from 'date-fns'; +import { LoaderCircleIcon } from 'lucide-react'; +import { useClient, useQuery } from 'urql'; +import { Page, TargetLayout } from '@/components/layouts/target'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { CardDescription } from '@/components/ui/card'; +import { EmptyList, noSchemaVersion } from '@/components/ui/empty-list'; +import { Meta } from '@/components/ui/meta'; +import { SubPageLayoutHeader } from '@/components/ui/page-content-layout'; +import { Spinner } from '@/components/ui/spinner'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { TimeAgo } from '@/components/v2'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { TooltipProvider } from '@radix-ui/react-tooltip'; +import { Link, useRouter } from '@tanstack/react-router'; + +const AppTableRow_AppDeploymentFragment = graphql(` + fragment AppTableRow_AppDeploymentFragment on AppDeployment { + id + name + version + status + totalDocumentCount + lastUsed + } +`); + +const TargetAppsViewQuery = graphql(` + query TargetAppsViewQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!, $after: String) { + organization(selector: { organization: $organizationId }) { + organization { + id + isAppDeploymentsEnabled + } + } + target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { + id + latestSchemaVersion { + id + __typename + } + appDeployments(first: 20, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + ...AppTableRow_AppDeploymentFragment + } + } + } + } + } +`); + +const TargetAppsViewFetchMoreQuery = graphql(` + query TargetAppsViewFetchMoreQuery( + $organizationId: ID! + $projectId: ID! + $targetId: ID! + $after: String! + ) { + target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) { + id + appDeployments(first: 20, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + ...AppTableRow_AppDeploymentFragment + } + } + } + } + } +`); + +function AppTableRow(props: { + organizationId: string; + projectId: string; + targetId: string; + appDeployment: FragmentType; +}) { + const appDeployment = useFragment(AppTableRow_AppDeploymentFragment, props.appDeployment); + + return ( + + + + {appDeployment.name}@{appDeployment.version} + + + + + {appDeployment.status} + + + {appDeployment.totalDocumentCount} + + {appDeployment.lastUsed ? ( + + + + {' '} + + +

+ {'Last operation reported on '} + {format(appDeployment.lastUsed, 'dd.MM.yyyy')} + {' at '} + {format(appDeployment.lastUsed, 'HH:mm')} +

+
+
+
+ ) : ( + + + + + No data + + + +

There was no usage reported yet.

+
+
+
+ )} +
+
+ ); +} + +function TargetAppsView(props: { organizationId: string; projectId: string; targetId: string }) { + const [data] = useQuery({ + query: TargetAppsViewQuery, + variables: { + organizationId: props.organizationId, + projectId: props.projectId, + targetId: props.targetId, + }, + }); + const client = useClient(); + const router = useRouter(); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const isAppDeploymentsEnabled = + !data.fetching && !data.stale && !data.data?.organization?.organization.isAppDeploymentsEnabled; + + useEffect(() => { + if (isAppDeploymentsEnabled) { + void router.navigate({ + to: '/$organizationId/$projectId/$targetId', + params: { + organizationId: props.organizationId, + projectId: props.projectId, + targetId: props.targetId, + }, + replace: true, + }); + } + }, [isAppDeploymentsEnabled]); + + return ( +
+ + + Group your GraphQL operations by app version for app version statistics and persisted + operations. + + {/* + + Learn more about App Deployments + + */} + + } + /> +
+ {data.fetching || data.stale ? ( +
+
+ +
Loading app deployments
+
+
+ ) : !data.data?.target?.latestSchemaVersion ? ( + noSchemaVersion + ) : !data.data.target.appDeployments ? ( + + ) : ( +
+
+ + + + App@Version + Status + + Amount of Documents + + + + + Last used + + Last time a request was sent for this app. Requires usage reporting being + set up. + + + + + + + + {data.data?.target?.appDeployments?.edges.map(edge => ( + + ))} + +
+
+
+ +
+
+ )} +
+ ); +} + +export function TargetAppsPage(props: { + organizationId: string; + projectId: string; + targetId: string; +}) { + return ( + <> + + + + + + ); +} diff --git a/packages/web/app/src/pages/target-laboratory.tsx b/packages/web/app/src/pages/target-laboratory.tsx index 3f31f25d9..265ae7989 100644 --- a/packages/web/app/src/pages/target-laboratory.tsx +++ b/packages/web/app/src/pages/target-laboratory.tsx @@ -362,6 +362,20 @@ function useOperationCollectionsPlugin(props: { useEffect(() => { if (!hasAllEditors || !currentOperation) { + const searchObj = router.latestLocation.search; + const operationString = + 'operationString' in searchObj && typeof searchObj.operationString === 'string' + ? searchObj.operationString + : null; + + // We provide an operation string when navigating to the laboratory from persisted documents + // in that case we want to show that operation within this tab. + if (operationString) { + editorContext.queryEditor?.setValue(operationString); + editorContext.variableEditor?.setValue(''); + editorContext.headerEditor?.setValue(''); + } + return; } diff --git a/packages/web/app/src/pages/target-settings.tsx b/packages/web/app/src/pages/target-settings.tsx index aed113432..d0a5cde89 100644 --- a/packages/web/app/src/pages/target-settings.tsx +++ b/packages/web/app/src/pages/target-settings.tsx @@ -139,7 +139,7 @@ function RegistryAccessTokens(props: { return ( @@ -244,7 +244,7 @@ const ExtendBaseSchema = (props: { return ( @@ -538,7 +538,7 @@ const ConditionalBreakingChanges = (props: {
@@ -784,7 +784,7 @@ function TargetName(props: { return ( @@ -901,7 +901,7 @@ function GraphQLEndpointUrl(props: { return ( @@ -1003,7 +1003,7 @@ function TargetDelete(props: { organizationId: string; projectId: string; target return ( diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index 17aa04008..d92f17f43 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -53,6 +53,8 @@ import { ProjectAlertsPage } from './pages/project-alerts'; import { ProjectPolicyPage } from './pages/project-policy'; import { ProjectSettingsPage } from './pages/project-settings'; import { TargetPage } from './pages/target'; +import { TargetAppVersionPage } from './pages/target-app-version'; +import { TargetAppsPage } from './pages/target-apps'; import { TargetChecksPage } from './pages/target-checks'; import { TargetChecksSinglePage } from './pages/target-checks-single'; import { TargetExplorerPage } from './pages/target-explorer'; @@ -549,6 +551,35 @@ const targetLaboratoryRoute = createRoute({ }, }); +const targetAppsRoute = createRoute({ + getParentRoute: () => targetRoute, + path: 'apps', + component: function TargetAppsRoute() { + const { organizationId, projectId, targetId } = targetAppsRoute.useParams(); + return ( + + ); + }, +}); + +const targetAppVersionRoute = createRoute({ + getParentRoute: () => targetRoute, + path: 'apps/$appName/$appVersion', + component: function TargetAppVersionRoute() { + const { organizationId, projectId, targetId, appName, appVersion } = + targetAppVersionRoute.useParams(); + return ( + + ); + }, +}); + const targetInsightsRoute = createRoute({ getParentRoute: () => targetRoute, path: 'insights', @@ -789,6 +820,8 @@ const routeTree = root.addChildren([ targetExplorerUnusedRoute, targetExplorerTypeRoute, targetChecksRoute.addChildren([targetChecksSingleRoute]), + targetAppVersionRoute, + targetAppsRoute, ]), ]), ]); diff --git a/packages/web/app/tailwind.config.cjs b/packages/web/app/tailwind.config.cjs index f6d2da23e..593daf2c9 100644 --- a/packages/web/app/tailwind.config.cjs +++ b/packages/web/app/tailwind.config.cjs @@ -256,6 +256,9 @@ module.exports = { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', }, + minHeight: { + content: 'var(--content-height)', + }, }, }, plugins: [