Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/admin-x-framework/src/api/automations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ObjectId from 'bson-objectid';
import { z } from 'zod';
import { Meta, createMutation, createQuery, createQueryWithId } from '../utils/api/hooks';
import type { ReadonlyDeep } from 'type-fest';

Expand Down Expand Up @@ -112,6 +113,52 @@ export const useReadAutomation = createQueryWithId<AutomationDetailResponseType>
path: (id) => `/automations/${id}/`,
});

export const AutomationPerformanceStatsSchema = z.object({
automation_id: z.string(),
total_run_count: z.number().int().nonnegative(),
in_progress_run_count: z.number().int().nonnegative(),
completed_run_count: z.number().int().nonnegative(),
exited_early_run_count: z.number().int().nonnegative(),
entries: z
.array(
z.object({
date: z.iso.date(),
count: z.number().int().nonnegative(),
}),
)
.min(1),
entry_window: z.object({
date_from: z.iso.date(),
date_to: z.iso.date(),
bucket: z.literal('day'),
timezone: z.string().min(1),
}),
});

const AutomationPerformanceStatsResponseSchema = z.object({
automation_performance_stats: z.array(AutomationPerformanceStatsSchema).length(1),
});

export type AutomationPerformanceStats = z.infer<typeof AutomationPerformanceStatsSchema>;

export const useReadAutomationPerformanceStats = (
id: string,
options?: Parameters<
ReturnType<typeof createQuery<z.infer<typeof AutomationPerformanceStatsResponseSchema>>>
>[0],
) => {
const useQuery = createQuery<z.infer<typeof AutomationPerformanceStatsResponseSchema>>({
dataType: 'AutomationPerformanceStatsResponseType',
path: `/automations/${id}/performance-stats/`,
parseResponse: (data) =>
AutomationPerformanceStatsResponseSchema.refine(
(response) => response.automation_performance_stats[0].automation_id === id,
{ message: 'Performance statistics do not match the requested automation.' },
).parse(data),
});
return useQuery(options);
};

const useBrowseAutomationActionLinksQuery = createQueryWithId<AutomationActionLinksResponseType>({
dataType: 'AutomationActionLinksResponseType',
path: (id) => `/automations/${id}/links/`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { AutomationPerformanceStatsSchema } from '../../../src/api/automations';

const stats = {
automation_id: 'one',
total_run_count: 1,
in_progress_run_count: 0,
completed_run_count: 1,
exited_early_run_count: 0,
entries: [{ date: '2026-01-01', count: 1 }],
entry_window: {
date_from: '2026-01-01',
date_to: '2026-01-02',
bucket: 'day',
timezone: 'UTC',
},
};

describe('AutomationPerformanceStatsSchema', () => {
it('accepts a valid response', () => {
expect(AutomationPerformanceStatsSchema.parse(stats)).toEqual(stats);
});

it('accepts statistics grouped in a non-UTC timezone', () => {
const localized = {
...stats,
entry_window: { ...stats.entry_window, timezone: 'America/New_York' },
};
expect(AutomationPerformanceStatsSchema.parse(localized)).toEqual(localized);
});

it.each([
{ name: 'empty series', overrides: { entries: [] } },
{ name: 'invalid date', overrides: { entries: [{ date: '2026-02-30', count: 1 }] } },
{ name: 'negative total', overrides: { total_run_count: -1 } },
{
name: 'fractional entry count',
overrides: { entries: [{ date: '2026-01-01', count: 1.5 }] },
},
...[-1, null, 1.5, '4'].map((count) => ({
name: `invalid status count (${JSON.stringify(count)})`,
overrides: { completed_run_count: count },
})),
])('rejects $name', ({ overrides }) => {
expect(AutomationPerformanceStatsSchema.safeParse({ ...stats, ...overrides }).success).toBe(
false,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ const AutomationCanvas: React.FC<AutomationCanvasProps> = ({
const initialViewport = useRef(getInitialViewport(window.innerWidth));
const automationAnalyticsEnabled = useFeatureFlag('automationAnalytics');
const automationRunAnalyticsEnabled = useFeatureFlag('automationRunAnalytics');
const automationsTinybirdSyncEnabled = useFeatureFlag('automationsTinybirdSync');

const graph = useMemo(() => {
if (!automation) {
Expand Down Expand Up @@ -679,11 +680,13 @@ const AutomationCanvas: React.FC<AutomationCanvasProps> = ({
return (
<Inline
align="stretch"
className="relative min-h-0 flex-1 overflow-hidden bg-background"
className="@container relative min-h-0 flex-1 overflow-hidden bg-background"
data-testid="automation-canvas"
gap="none"
>
{automationRunAnalyticsEnabled && <PerformanceSidebar />}
{automationRunAnalyticsEnabled && automationsTinybirdSyncEnabled && (
<PerformanceSidebar automationId={automation.id} />
)}
<div ref={viewport.measureCanvas} className="relative min-w-0 flex-1">
<ReactFlow
className="[--xy-background-color:var(--color-gray-50)] [--xy-background-pattern-color:var(--color-gray-500)] [--xy-edge-stroke:var(--color-gray-300)] dark:[--xy-background-color:var(--background)] dark:[--xy-background-pattern-color:var(--color-gray-900)] dark:[--xy-edge-stroke:var(--color-gray-800)]"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React, { useId, useState } from 'react';
import { Button } from '@tryghost/shade/components';
import { Box, Inline, Text } from '@tryghost/shade/primitives';
import { Box, Inline, Stack, Text } from '@tryghost/shade/primitives';
import { LucideIcon, cn } from '@tryghost/shade/utils';
import { TotalEntries } from './total-entries';

export const PerformanceSidebar: React.FC = () => {
export const PerformanceSidebar: React.FC<{ automationId: string }> = ({ automationId }) => {
const [isOpen, setIsOpen] = useState(false);
const [hasOpened, setHasOpened] = useState(false);
const panelId = useId();
const headingId = useId();

Expand All @@ -18,25 +20,39 @@ export const PerformanceSidebar: React.FC = () => {
size="icon"
type="button"
variant="ghost"
onClick={() => setIsOpen((open) => !open)}
onClick={() => {
setHasOpened(true);
setIsOpen((open) => !open);
}}
>
<LucideIcon.PanelLeft strokeWidth={2} />
</Button>
<aside
ref={(panel) => {
if (panel) {
panel.inert = !isOpen;
}
}}
aria-hidden={!isOpen}
aria-labelledby={headingId}
className={cn(
'max-w-[calc(100%-6rem)] shrink-0 overflow-hidden border-border-default bg-surface-elevated transition-[width] duration-150 ease-out motion-reduce:transition-none',
isOpen ? 'w-[480px] border-r' : 'w-0',
'shrink-0 overflow-hidden bg-surface-elevated transition-[width] duration-150 ease-out motion-reduce:transition-none',
isOpen ? 'w-[min(480px,calc(100cqw-6rem))]' : 'w-0',
)}
id={panelId}
>
<Box className="w-[480px] px-6 py-4">
{/* Keep content at its full width while the sidebar animates open or closed. */}
<Box className="h-full w-[min(480px,calc(100cqw-6rem))] overflow-y-auto border-r border-border-default px-6 py-4">
<Inline className="h-9 pl-10" gap="none">
<Text as="h2" id={headingId} size="md" weight="semibold">
Performance
</Text>
</Inline>
{hasOpened && (
<Stack className="mt-4" gap="md">
<TotalEntries automationId={automationId} />
</Stack>
)}
</Box>
</aside>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { useId } from 'react';
import { GhAreaChart } from '@tryghost/shade/patterns';
import { Text } from '@tryghost/shade/primitives';
import type { AutomationEntriesChartData } from '@/automations/utils/automation-entry-stats';

export const TotalEntriesChart: React.FC<{ data: AutomationEntriesChartData }> = ({ data }) => {
const id = useId();
return (
<>
<Text className="tabular-nums" size="2xl" weight="semibold">
{data.total}
</Text>
<figure aria-label="Automation entries" className="relative">
<figcaption className="sr-only">
All-time automation entries from {data.startDate} to {data.endDate}, in {data.timezone}.
</figcaption>
<GhAreaChart
className="h-[180px]"
data={data.points}
id={id}
range={data.range}
showYAxisValues={false}
yAxisRange={[0, data.max]}
/>
{data.empty && (
<Text
className="absolute inset-x-0 top-1/2 -translate-y-1/2 text-center"
size="sm"
tone="secondary"
>
No entries yet
</Text>
)}
</figure>
</>
);
};
56 changes: 56 additions & 0 deletions apps/admin/src/automations/components/canvas/total-entries.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { useId } from 'react';
import { Button, Skeleton } from '@tryghost/shade/components';
import { Inline, Stack, Text } from '@tryghost/shade/primitives';
import { LucideIcon } from '@tryghost/shade/utils';
import { useAutomationEntryStats } from '@/automations/hooks/use-automation-entry-stats';
import { TotalEntriesChart } from './total-entries-chart';

export const TotalEntries: React.FC<{ automationId: string }> = ({ automationId }) => {
const { chart, isLoading, isError, retry } = useAutomationEntryStats(automationId);
const headingId = useId();

return (
<Stack
aria-labelledby={headingId}
className="rounded-lg border border-border-default p-4"
gap="xs"
role="region"
>
<Inline gap="xs">
<LucideIcon.User className="size-3.5 text-muted-foreground" />
<Text as="h3" id={headingId} size="sm" tone="secondary">
Total entries
</Text>
</Inline>
<Text aria-atomic="true" className="sr-only" role="status">
{isLoading
? 'Loading total entries'
: !isError && chart
? `Total entries loaded: ${chart.total}.`
: ''}
</Text>
{isLoading && (
<Stack aria-hidden="true" gap="xs">
<Text as="div" size="2xl">
<Skeleton className="h-[1em] w-20" />
</Text>
<Skeleton
className="h-[180px] w-full"
containerClassName="block h-[180px] leading-none"
/>
</Stack>
)}
{chart && <TotalEntriesChart data={chart} />}
{isError && (
<Stack className="py-3" gap="sm" role="alert">
<Text size="sm" tone="secondary">
Could not load entries.
</Text>
<Button className="self-start" size="sm" variant="outline" onClick={retry}>
Retry
</Button>
</Stack>
)}
</Stack>
);
};
33 changes: 20 additions & 13 deletions apps/admin/src/automations/editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1040,22 +1040,29 @@ describe('AutomationEditor', () => {
expect(mockUseBrowseAutomationActionLinks).not.toHaveBeenCalled();
});

it('hides the performance toggle and panel when run analytics is disabled', () => {
mockLabs.current = { automationRunAnalytics: false };
mockUseReadAutomation.mockReturnValue({
data: { automations: [automationDetail] },
isLoading: false,
isError: false,
});
it.each(['automationRunAnalytics', 'automationsTinybirdSync'])(
'hides the performance toggle and panel when %s is disabled',
(flag) => {
mockLabs.current = {
automationRunAnalytics: true,
automationsTinybirdSync: true,
[flag]: false,
};
mockUseReadAutomation.mockReturnValue({
data: { automations: [automationDetail] },
isLoading: false,
isError: false,
});

renderEditor();
renderEditor();

expect(screen.queryByRole('button', { name: /performance/i })).not.toBeInTheDocument();
expect(screen.queryByText('Performance')).not.toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /performance/i })).not.toBeInTheDocument();
expect(screen.queryByText('Performance')).not.toBeInTheDocument();
},
);

it('renders the performance toggle and panel shell when run analytics is enabled', () => {
mockLabs.current = { automationRunAnalytics: true };
it('renders the performance toggle and panel shell when both analytics flags are enabled', () => {
mockLabs.current = { automationRunAnalytics: true, automationsTinybirdSync: true };
mockUseReadAutomation.mockReturnValue({
data: { automations: [automationDetail] },
isLoading: false,
Expand Down
11 changes: 11 additions & 0 deletions apps/admin/src/automations/hooks/performance-query-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Sidebar content mounts on first open and stays mounted until navigation.
export const performanceQueryOptions = {
searchParams: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
defaultErrorHandler: false,
staleTime: Infinity,
gcTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
} as const;
20 changes: 20 additions & 0 deletions apps/admin/src/automations/hooks/use-automation-entry-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { performanceQueryOptions } from './performance-query-options';
import { useMemo } from 'react';
import { useReadAutomationPerformanceStats } from '@tryghost/admin-x-framework/api/automations';
import { mapAutomationEntryStats } from '@/automations/utils/automation-entry-stats';

export const useAutomationEntryStats = (automationId: string) => {
const query = useReadAutomationPerformanceStats(automationId, performanceQueryOptions);
const stats = query.data?.automation_performance_stats[0];
const chart = useMemo(() => (stats ? mapAutomationEntryStats(stats) : undefined), [stats]);
const failed = !query.isFetching && query.isError;

return {
chart,
isLoading: !chart && !failed,
isError: failed,
retry: () => {
void query.refetch();
},
};
};
Loading
Loading