-
{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 (
+ <>
+
+
+
+
+
App Version not found.
+
This app does not seem to exist anymore.
+
+ Go back
+
+
+
+ >
+ );
+ }
+
+ 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
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {
+ if (
+ data?.data?.target?.appDeployment?.documents?.pageInfo?.endCursor &&
+ data?.data?.target?.appDeployment?.documents?.pageInfo?.hasNextPage
+ ) {
+ setIsLoadingMore(true);
+ void client
+ .query(TargetAppsVersionQuery, {
+ organizationId: props.organizationId,
+ projectId: props.projectId,
+ targetId: props.targetId,
+ appName: props.appName,
+ appVersion: props.appVersion,
+ first: 20,
+ after: data?.data?.target?.appDeployment?.documents.pageInfo?.endCursor,
+ })
+ .toPromise()
+ .finally(() => {
+ setIsLoadingMore(false);
+ });
+ }
+ }}
+ >
+ {isLoadingMore ? (
+ <>
+ Loading
+ >
+ ) : (
+ 'Load more'
+ )}
+
+
+ >
+ )}
+
+
+ >
+ );
+}
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 => (
+
+ ))}
+
+
+
+
+ {
+ if (
+ data?.data?.target?.appDeployments?.pageInfo?.endCursor &&
+ data?.data?.target?.appDeployments?.pageInfo?.hasNextPage
+ ) {
+ setIsLoadingMore(true);
+ void client
+ .query(TargetAppsViewFetchMoreQuery, {
+ organizationId: props.organizationId,
+ projectId: props.projectId,
+ targetId: props.targetId,
+ after: data?.data?.target?.appDeployments?.pageInfo?.endCursor,
+ })
+ .toPromise()
+ .finally(() => {
+ setIsLoadingMore(false);
+ });
+ }
+ }}
+ >
+ {isLoadingMore ? (
+ <>
+ Loading
+ >
+ ) : (
+ 'Load more'
+ )}
+
+
+
+ )}
+
+ );
+}
+
+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: {