feat(app-deployments): add createdAt, activatedAt, retiredAt fields and UI/UX improvements (#7669)

This commit is contained in:
Adam Benhassen 2026-02-12 16:42:02 +02:00 committed by GitHub
parent 5b24204550
commit a6f5ac4cc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 323 additions and 31 deletions

View file

@ -0,0 +1,5 @@
---
'hive': patch
---
Add `lastUsed`, `createdAt`, `activatedAt`, and `status` columns to app deployments tables. Fix broken text colors on multiple pages after the recent color palette overhaul.

View file

@ -17,6 +17,14 @@ export default gql`
"""
createdAt: DateTime! @tag(name: "public")
"""
The timestamp when the app deployment was activated.
"""
activatedAt: DateTime @tag(name: "public")
"""
The timestamp when the app deployment was retired. Only present for retired deployments.
"""
retiredAt: DateTime @tag(name: "public")
"""
The last time a GraphQL request that used the app deployment was reported.
"""
lastUsed: DateTime @tag(name: "public")

View file

@ -1097,6 +1097,46 @@ export class AppDeployments {
activeDeployments.map(d => [d.appDeploymentId, { name: d.appName, version: d.appVersion }]),
);
const deploymentIds = activeDeployments.map(d => d.appDeploymentId);
let timestampsResult;
try {
timestampsResult = await this.pool.query<{
id: string;
createdAt: string;
activatedAt: string | null;
retiredAt: string | null;
}>(
sql`
SELECT
"id",
to_json("created_at") AS "createdAt",
to_json("activated_at") AS "activatedAt",
to_json("retired_at") AS "retiredAt"
FROM "app_deployments"
WHERE "id" = ANY(${sql.array(deploymentIds, 'uuid')})
`,
);
} catch (error) {
this.logger.error(
'Failed to fetch deployment timestamps from postgres (targetId=%s, deploymentCount=%d): %s',
args.targetId,
deploymentIds.length,
error instanceof Error ? error.message : String(error),
);
throw error;
}
const deploymentTimestamps = new Map(
timestampsResult.rows.map(row => [
row.id,
{
createdAt: row.createdAt,
activatedAt: row.activatedAt,
retiredAt: row.retiredAt,
},
]),
);
// Count total affected deployments
let countResult;
try {
@ -1355,11 +1395,15 @@ export class AppDeployments {
}
}
const timestamps = deploymentTimestamps.get(deploymentId);
deployments.push({
appDeployment: {
id: deploymentId,
name: info.name,
version: info.version,
createdAt: timestamps?.createdAt ?? null,
activatedAt: timestamps?.activatedAt ?? null,
retiredAt: timestamps?.retiredAt ?? null,
},
affectedOperationsByCoordinate: operations,
countByCoordinate: coordCounts ? Object.fromEntries(coordCounts) : {},

View file

@ -315,6 +315,10 @@ export type SchemaChangeAffectedAppDeploymentMapper = {
id: string;
name: string;
version: string;
createdAt: string | null;
activatedAt: string | null;
retiredAt: string | null;
status: 'pending' | 'active' | 'retired';
operations: Array<{
hash: string;
name: string | null;

View file

@ -605,6 +605,26 @@ export default gql`
"""
version: String! @tag(name: "public")
"""
The timestamp when the app deployment was created.
"""
createdAt: DateTime @tag(name: "public")
"""
The timestamp when the app deployment was activated.
"""
activatedAt: DateTime @tag(name: "public")
"""
The current status of the app deployment.
"""
status: AppDeploymentStatus!
"""
The timestamp when the app deployment was retired. Only present for retired deployments.
"""
retiredAt: DateTime @tag(name: "public")
"""
The last time a GraphQL request that used the app deployment was reported.
"""
lastUsed: DateTime @tag(name: "public")
"""
The operations within this app deployment that use the affected schema coordinate.
"""
affectedOperations(

View file

@ -46,6 +46,9 @@ export type AffectedAppDeployment = {
id: string;
name: string;
version: string;
createdAt: string | null;
activatedAt: string | null;
retiredAt: string | null;
};
affectedOperationsByCoordinate: Record<string, Array<{ hash: string; name: string | null }>>;
countByCoordinate: Record<string, number>;
@ -714,6 +717,9 @@ export class RegistryChecks {
id: d.appDeployment.id,
name: d.appDeployment.name,
version: d.appDeployment.version,
createdAt: d.appDeployment.createdAt,
activatedAt: d.appDeployment.activatedAt,
retiredAt: d.appDeployment.retiredAt,
affectedOperations: d.affectedOperationsByCoordinate[coordinate],
}));
}

View file

@ -74,6 +74,10 @@ export const SchemaChange: Pick<
id: d.id,
name: d.name,
version: d.version,
createdAt: d.createdAt ?? null,
activatedAt: d.activatedAt ?? null,
retiredAt: d.retiredAt ?? null,
status: d.retiredAt ? ('retired' as const) : ('active' as const),
operations: d.affectedOperations,
totalOperations: d.affectedOperations.length,
},

View file

@ -1,6 +1,12 @@
import { AppDeploymentsManager } from '../../app-deployments/providers/app-deployments-manager';
import type { SchemaChangeAffectedAppDeploymentResolvers } from './../../../__generated__/types';
export const SchemaChangeAffectedAppDeployment: SchemaChangeAffectedAppDeploymentResolvers = {
lastUsed: async (deployment, _, { injector }) => {
return injector
.get(AppDeploymentsManager)
.getLastUsedForAppDeployment({ id: deployment.id } as any);
},
affectedOperations: (deployment, args) => {
const allOperations = (deployment.operations ?? []).map(op => ({
hash: op.hash,

View file

@ -1304,6 +1304,9 @@ export const HiveSchemaChangeModel = z
id: z.string(),
name: z.string(),
version: z.string(),
createdAt: z.string().nullable().optional(),
activatedAt: z.string().nullable().optional(),
retiredAt: z.string().nullable().optional(),
affectedOperations: z.array(
z.object({
hash: z.string(),
@ -1344,6 +1347,9 @@ export const HiveSchemaChangeModel = z
id: string;
name: string;
version: string;
createdAt?: string | null;
activatedAt?: string | null;
retiredAt?: string | null;
affectedOperations: { hash: string; name: string | null }[];
}[]
| null;

View file

@ -24,6 +24,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { TimeAgo } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { SeverityLevelType } from '@/gql/graphql';
import { CheckCircledIcon, InfoCircledIcon } from '@radix-ui/react-icons';
@ -101,6 +102,8 @@ const ChangesBlock_SchemaChangeWithUsageFragment = graphql(`
id
name
version
activatedAt
lastUsed
affectedOperations(first: 5) {
edges {
cursor
@ -243,7 +246,7 @@ function ChangeItem(
{'usageStatistics' in change && change.usageStatistics && (
<>
{' '}
<span className="bg-neutral-5 inline-flex items-center space-x-1 rounded-sm px-2 py-1 align-middle font-bold">
<span className="bg-neutral-5 inline-flex items-center space-x-1 rounded-sm px-2 py-1 align-middle font-bold text-red-400">
<PulseIcon className="h-4 stroke-[1px]" />
<span className="text-xs">
{change.usageStatistics.topAffectedOperations.length}
@ -263,7 +266,7 @@ function ChangeItem(
{'affectedAppDeployments' in change && change.affectedAppDeployments?.totalCount ? (
<>
{' '}
<span className="inline-flex items-center space-x-1 rounded-sm bg-orange-500 px-2 py-1 align-middle font-bold">
<span className="text-neutral-1 inline-flex items-center space-x-1 rounded-sm bg-orange-500 px-2 py-1 align-middle font-bold">
<BoxIcon className="size-4 stroke-[2px]" />
<span className="text-xs">
{change.affectedAppDeployments.totalCount}{' '}
@ -425,13 +428,16 @@ function ChangeItem(
Affected App Deployments
</h4>
<p className="text-neutral-10 mb-2 text-sm">
Top 5 active app deployments that have operations using this schema coordinate.
Top 5 active app deployments that have operations using this schema coordinate
(snapshot from when the check was run).
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">App Name</TableHead>
<TableHead>Version</TableHead>
<TableHead>Activated</TableHead>
<TableHead className="text-end">Last Used</TableHead>
<TableHead className="text-right">Affected Operations</TableHead>
</TableRow>
</TableHeader>
@ -455,6 +461,33 @@ function ChangeItem(
</Link>
</TableCell>
<TableCell>{deployment.version}</TableCell>
<TableCell>
{deployment.activatedAt ? (
<span className="text-neutral-11 cursor-help text-xs">
<TimeAgo date={deployment.activatedAt} />
</span>
) : (
<span className="text-neutral-10 text-xs"></span>
)}
</TableCell>
<TableCell className="text-end">
{deployment.lastUsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span className="text-neutral-11 cursor-help text-xs">
<TimeAgo date={deployment.lastUsed} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{format(deployment.lastUsed, 'MMM d, yyyy HH:mm:ss')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-neutral-10 text-xs"></span>
)}
</TableCell>
<TableCell className="text-right">
<Popover>
<PopoverTrigger asChild>
@ -511,7 +544,7 @@ function ChangeItem(
schemaCheckId: props.schemaCheckId,
}}
search={{ coordinate: change.path?.join('.') }}
className="text-neutral-2 mt-2 block text-sm hover:underline"
className="mt-2 block text-sm text-orange-500 hover:underline"
>
View all ({change.affectedAppDeployments.totalCount}) affected app deployments
</Link>
@ -523,13 +556,16 @@ function ChangeItem(
<div>
<h4 className="text-neutral-12 mb-1 text-sm font-medium">Affected App Deployments</h4>
<p className="text-neutral-10 mb-2 text-sm">
Top 5 active app deployments that have operations using this schema coordinate.
Top 5 active app deployments that have operations using this schema coordinate
(snapshot from when the check was run).
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">App Name</TableHead>
<TableHead>Version</TableHead>
<TableHead>Activated</TableHead>
<TableHead className="text-end">Last Used</TableHead>
<TableHead className="text-right">Affected Operations</TableHead>
</TableRow>
</TableHeader>
@ -553,6 +589,33 @@ function ChangeItem(
</Link>
</TableCell>
<TableCell>{deployment.version}</TableCell>
<TableCell>
{deployment.activatedAt ? (
<span className="text-neutral-11 cursor-help text-xs">
<TimeAgo date={deployment.activatedAt} />
</span>
) : (
<span className="text-neutral-10 text-xs"></span>
)}
</TableCell>
<TableCell className="text-end">
{deployment.lastUsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span className="text-neutral-11 cursor-help text-xs">
<TimeAgo date={deployment.lastUsed} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{format(deployment.lastUsed, 'MMM d, yyyy HH:mm:ss')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-neutral-10 text-xs"></span>
)}
</TableCell>
<TableCell className="text-right">
<Popover>
<PopoverTrigger asChild>
@ -606,7 +669,7 @@ function ChangeItem(
schemaCheckId: props.schemaCheckId,
}}
search={{ coordinate: change.path?.join('.') }}
className="text-neutral-2 mt-2 block text-sm hover:underline"
className="mt-2 block text-sm text-orange-500 hover:underline"
>
View all ({change.affectedAppDeployments.totalCount}) affected app deployments
</Link>

View file

@ -0,0 +1,18 @@
import { format } from 'date-fns';
import { TimeAgo } from '@/components/v2';
export function DateWithTimeAgo(props: {
date: string;
dateFormatStr?: string;
}): React.ReactElement {
const { date, dateFormatStr = 'MMM d, yyyy' } = props;
return (
<>
{format(date, dateFormatStr)}{' '}
<span className="text-neutral-10 font-normal">
(<TimeAgo date={date} />)
</span>
</>
);
}

View file

@ -0,0 +1,18 @@
import { format } from 'date-fns';
export function DeploymentStatusLabel(props: {
status: string;
retiredAt?: string | null;
}): React.ReactElement {
const { status, retiredAt } = props;
if (status === 'retired' && retiredAt) {
return (
<span>
{status} ({format(retiredAt, 'MMM d, yyyy HH:mm:ss')})
</span>
);
}
return <>{status}</>;
}

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { format } from 'date-fns';
import { LoaderCircleIcon } from 'lucide-react';
import { useClient, useQuery } from 'urql';
import { AppFilter } from '@/components/apps/AppFilter';
@ -6,6 +7,7 @@ import { NotFoundContent } from '@/components/common/not-found-content';
import { Page, TargetLayout } from '@/components/layouts/target';
import { Button } from '@/components/ui/button';
import { CardDescription } from '@/components/ui/card';
import { DateWithTimeAgo } from '@/components/ui/date-with-time-ago';
import {
DropdownMenu,
DropdownMenuContent,
@ -27,7 +29,6 @@ import {
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { TimeAgo } from '@/components/v2';
import { graphql } from '@/gql';
import { AppDeploymentStatus } from '@/gql/graphql';
import { useRedirect } from '@/lib/access/common';
@ -61,6 +62,8 @@ const TargetAppsVersionQuery = graphql(`
name
version
createdAt
activatedAt
retiredAt
lastUsed
totalDocumentCount
status
@ -277,7 +280,7 @@ function TargetAppVersionContent(props: {
appName: props.appName,
appVersion: props.appVersion,
}}
className="text-neutral-2 hover:underline"
className="text-orange-500 hover:underline"
>
Clear filter
</Link>
@ -320,7 +323,14 @@ function TargetAppVersionContent(props: {
appDeployment?.status === AppDeploymentStatus.Pending && 'text-neutral-11',
)}
>
{appDeployment?.status.toUpperCase() ?? '...'}
{appDeployment?.status === AppDeploymentStatus.Retired &&
appDeployment?.retiredAt ? (
<span>
RETIRED ({format(appDeployment.retiredAt, 'MMM d, yyyy HH:mm:ss')})
</span>
) : (
(appDeployment?.status.toUpperCase() ?? '...')
)}
</div>
</div>
<div className="min-w-0">
@ -329,20 +339,46 @@ function TargetAppVersionContent(props: {
{appDeployment?.totalDocumentCount ?? '...'}
</div>
</div>
<div className="min-w-0 text-xs">
Created{' '}
{appDeployment?.createdAt ? <TimeAgo date={appDeployment.createdAt} /> : '...'}
<div className="min-w-0">
<div className="text-xs">Created</div>
<div className="text-neutral-12 text-sm font-semibold">
{appDeployment?.createdAt ? (
<DateWithTimeAgo
date={appDeployment.createdAt}
dateFormatStr="MMM d, yyyy HH:mm:ss"
/>
) : (
'...'
)}
</div>
</div>
<div className="min-w-0 text-xs">
{data.fetching ? (
'...'
) : appDeployment?.lastUsed ? (
<>
Last Used <TimeAgo date={appDeployment.lastUsed} />
</>
) : (
'No Usage Data'
)}
<div className="min-w-0">
<div className="text-xs">Activated</div>
<div className="text-neutral-12 text-sm font-semibold">
{appDeployment?.activatedAt ? (
<DateWithTimeAgo
date={appDeployment.activatedAt}
dateFormatStr="MMM d, yyyy HH:mm:ss"
/>
) : (
<span className="text-neutral-10 font-normal"></span>
)}
</div>
</div>
<div className="min-w-0">
<div className="text-xs">Last Used</div>
<div className="text-neutral-12 text-sm font-semibold">
{data.fetching ? (
'...'
) : appDeployment?.lastUsed ? (
<DateWithTimeAgo
date={appDeployment.lastUsed}
dateFormatStr="MMM d, yyyy HH:mm:ss"
/>
) : (
<span className="text-neutral-10 font-normal">No Usage Data</span>
)}
</div>
</div>
</div>
</div>

View file

@ -6,6 +6,8 @@ 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 { DateWithTimeAgo } from '@/components/ui/date-with-time-ago';
import { DeploymentStatusLabel } from '@/components/ui/deployment-status';
import { EmptyList, NoSchemaVersion } from '@/components/ui/empty-list';
import { Meta } from '@/components/ui/meta';
import { SubPageLayoutHeader } from '@/components/ui/page-content-layout';
@ -33,6 +35,9 @@ const AppTableRow_AppDeploymentFragment = graphql(`
version
status
totalDocumentCount
createdAt
activatedAt
retiredAt
lastUsed
}
`);
@ -142,24 +147,38 @@ function AppTableRow(props: {
</TableCell>
<TableCell className="hidden text-center sm:table-cell">
<Badge className="text-xs" variant="secondary">
{appDeployment.status}
<DeploymentStatusLabel
status={appDeployment.status}
retiredAt={appDeployment.retiredAt}
/>
</Badge>
</TableCell>
<TableCell className="text-center">{appDeployment.totalDocumentCount}</TableCell>
<TableCell className="hidden text-center sm:table-cell">
<span className="text-xs">
<DateWithTimeAgo date={appDeployment.createdAt} />
</span>
</TableCell>
<TableCell className="hidden text-center sm:table-cell">
{appDeployment.activatedAt ? (
<span className="text-xs">
<DateWithTimeAgo date={appDeployment.activatedAt} />
</span>
) : (
<span className="text-neutral-10 text-xs"></span>
)}
</TableCell>
<TableCell className="text-end">
{appDeployment.lastUsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TimeAgo date={appDeployment.lastUsed} className="cursor-help text-xs" />{' '}
<Badge className="cursor-help text-xs" variant="outline">
<TimeAgo date={appDeployment.lastUsed} />
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>
{'Last operation reported on '}
{format(appDeployment.lastUsed, 'dd.MM.yyyy')}
{' at '}
{format(appDeployment.lastUsed, 'HH:mm')}
</p>
<p>{format(appDeployment.lastUsed, 'MMM d, yyyy HH:mm:ss')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -281,6 +300,8 @@ function TargetAppsView(props: {
<TableHead className="hidden text-center sm:table-cell">
Amount of Documents
</TableHead>
<TableHead className="hidden text-center sm:table-cell">Created</TableHead>
<TableHead className="hidden text-center sm:table-cell">Activated</TableHead>
<TableHead className="hidden text-end sm:table-cell">
<TooltipProvider>
<Tooltip>

View file

@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { useQuery } from 'urql';
import { Page, TargetLayout } from '@/components/layouts/target';
import { Button } from '@/components/ui/button';
import { DateWithTimeAgo } from '@/components/ui/date-with-time-ago';
import { EmptyList } from '@/components/ui/empty-list';
import { Meta } from '@/components/ui/meta';
import { SubPageLayoutHeader } from '@/components/ui/page-content-layout';
@ -54,6 +55,9 @@ const AffectedDeploymentsQuery = graphql(`
name
version
totalAffectedOperations
activatedAt
retiredAt
lastUsed
}
}
totalCount
@ -80,6 +84,9 @@ const AffectedDeploymentsQuery = graphql(`
name
version
totalAffectedOperations
activatedAt
retiredAt
lastUsed
}
}
totalCount
@ -102,6 +109,9 @@ type AffectedDeployment = {
name: string;
version: string;
totalOperations: number;
activatedAt: string | null;
retiredAt: string | null;
lastUsed: string | null;
};
const PAGE_SIZE = 20;
@ -167,6 +177,9 @@ function TargetChecksAffectedDeploymentsContent(props: {
name: edge.node.name,
version: edge.node.version,
totalOperations: edge.node.totalAffectedOperations,
activatedAt: edge.node.activatedAt ?? null,
retiredAt: edge.node.retiredAt ?? null,
lastUsed: edge.node.lastUsed ?? null,
}),
) ?? [];
@ -231,7 +244,7 @@ function TargetChecksAffectedDeploymentsContent(props: {
targetSlug: props.targetSlug,
schemaCheckId: props.schemaCheckId,
}}
className="text-neutral-2 hover:underline"
className="text-orange-500 hover:underline"
>
Schema Check
</Link>
@ -277,6 +290,8 @@ function TargetChecksAffectedDeploymentsContent(props: {
<TableRow>
<TableHead className="w-[200px]">App Name</TableHead>
<TableHead>Version</TableHead>
<TableHead>Activated</TableHead>
<TableHead>Last Used</TableHead>
<TableHead className="text-right">Total Operations</TableHead>
</TableRow>
</TableHeader>
@ -302,6 +317,24 @@ function TargetChecksAffectedDeploymentsContent(props: {
</Link>
</TableCell>
<TableCell>{deployment.version}</TableCell>
<TableCell>
{deployment.activatedAt ? (
<span className="text-xs">
<DateWithTimeAgo date={deployment.activatedAt} />
</span>
) : (
<span className="text-neutral-10 text-xs"></span>
)}
</TableCell>
<TableCell>
{deployment.lastUsed ? (
<span className="text-xs">
<DateWithTimeAgo date={deployment.lastUsed} />
</span>
) : (
<span className="text-neutral-10 text-xs"></span>
)}
</TableCell>
<TableCell className="text-right">
<Button variant="link" className="h-auto p-0" asChild>
<Link