From 22bee13918ca7a09a706d9fa0dcfdae108ec82ed Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 10 Aug 2026 13:46:56 -0400 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=8E=A8=20Improved=20automation=20run?= =?UTF-8?q?=20analytics=20prototype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref Surfaced run activity at list and workflow levels while keeping the prototype scoped to core progress and completion signals. --- .../components/automations-list.test.tsx | 7 ++ .../components/automations-list.tsx | 34 +++++++- .../components/run-analytics-sidebar.test.tsx | 31 +++++++ .../components/run-analytics-sidebar.tsx | 82 +++++++++++++++++++ apps/admin/src/automations/editor.tsx | 50 +++++------ apps/admin/src/automations/run-analytics.ts | 66 +++++++++++++++ 6 files changed, 243 insertions(+), 27 deletions(-) create mode 100644 apps/admin/src/automations/components/run-analytics-sidebar.test.tsx create mode 100644 apps/admin/src/automations/components/run-analytics-sidebar.tsx create mode 100644 apps/admin/src/automations/run-analytics.ts diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index f5de0d8b84c..a5a11858de1 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -29,6 +29,13 @@ describe('AutomationsList', () => { expect(screen.getByText('Welcome new paid members after they start their subscription.')).toBeInTheDocument(); expect(screen.getByText('Live')).toBeInTheDocument(); expect(screen.getByText('Off')).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Last run'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'In progress'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Completed'})).toBeInTheDocument(); + expect(screen.getByText('118')).toBeInTheDocument(); + expect(screen.getByText('1,225')).toBeInTheDocument(); + expect(screen.getByText('61')).toBeInTheDocument(); + expect(screen.getByText('320')).toBeInTheDocument(); }); it('links each row to the automation sequence by id', () => { diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index 6ad5ec513d0..8907e853301 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -2,7 +2,9 @@ import AutomationStatusBadge from './automation-status-badge'; import React from 'react'; import type {Automation} from '@tryghost/admin-x-framework/api/automations'; import {Link} from '@tryghost/admin-x-framework'; -import {Skeleton, Table, TableBody, TableCell, TableRow} from '@tryghost/shade/components'; +import {Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; +import {cn, formatNumber} from '@tryghost/shade/utils'; +import {formatLastRun, getAutomationRunAnalytics} from '@/automations/run-analytics'; const AUTOMATION_DESCRIPTIONS: Record = { 'member-welcome-email-free': 'Welcome new free members after they sign up.', @@ -14,6 +16,8 @@ interface AutomationsListProps { isLoading?: boolean; } +const gridColumns = 'grid grid-cols-[1fr_auto] lg:grid-cols-[minmax(0,1fr)_170px_130px_130px_110px]'; + const AutomationsListSkeleton: React.FC = () => { return ( @@ -22,12 +26,15 @@ const AutomationsListSkeleton: React.FC = () => {
+ + + Name + Last run + In progress + Completed + Status + + {automations.map((automation) => { const description = AUTOMATION_DESCRIPTIONS[automation.slug]; + const metrics = getAutomationRunAnalytics(automation); return ( @@ -70,7 +87,16 @@ const AutomationsList: React.FC = ({automations = [], isLo )} - + + {formatLastRun(metrics.lastRunAt)} + + + {formatNumber(metrics.inProgress)} + + + {formatNumber(metrics.completed)} + + diff --git a/apps/admin/src/automations/components/run-analytics-sidebar.test.tsx b/apps/admin/src/automations/components/run-analytics-sidebar.test.tsx new file mode 100644 index 00000000000..16bc1f6fd30 --- /dev/null +++ b/apps/admin/src/automations/components/run-analytics-sidebar.test.tsx @@ -0,0 +1,31 @@ +import RunAnalyticsSidebar from './run-analytics-sidebar'; +import type {GhAreaChartDataItem} from '@tryghost/shade/patterns'; +import {describe, expect, it, vi} from 'vitest'; +import {render, screen} from '@testing-library/react'; + +vi.mock('@tryghost/shade/patterns', () => ({ + GhAreaChart: ({data}: {data: GhAreaChartDataItem[]}) =>
+})); + +describe('RunAnalyticsSidebar', () => { + it('shows the run graph and only the requested run status totals', () => { + render( + + ); + + expect(screen.getByTestId('runs-chart')).toHaveAttribute('data-point-count', '30'); + expect(screen.getByText('Total runs')).toBeInTheDocument(); + expect(screen.getByText('1,432')).toBeInTheDocument(); + expect(screen.getByText('In progress')).toBeInTheDocument(); + expect(screen.getByText('118')).toBeInTheDocument(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + expect(screen.getByText('1,225')).toBeInTheDocument(); + expect(screen.queryByText('Upgraded')).not.toBeInTheDocument(); + expect(screen.queryByText('Unsubscribed')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/admin/src/automations/components/run-analytics-sidebar.tsx b/apps/admin/src/automations/components/run-analytics-sidebar.tsx new file mode 100644 index 00000000000..409e7e0bd25 --- /dev/null +++ b/apps/admin/src/automations/components/run-analytics-sidebar.tsx @@ -0,0 +1,82 @@ +import React, {useMemo, useState} from 'react'; +import type {Automation} from '@tryghost/admin-x-framework/api/automations'; +import {MetricValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; +import {Box, Grid, Inline, Stack, Text} from '@tryghost/shade/primitives'; +import {GhAreaChart} from '@tryghost/shade/patterns'; +import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; +import {getAutomationRunAnalytics} from '@/automations/run-analytics'; + +const MetricTile: React.FC<{label: string; value: number; color: string}> = ({label, value, color}) => ( + + + + {label} + + )} + value={formatNumber(value)} + /> + +); + +const RunAnalyticsSidebar: React.FC<{automation: Automation}> = ({automation}) => { + const [range, setRange] = useState('30'); + const metrics = getAutomationRunAnalytics(automation); + const chartData = useMemo(() => metrics.enrollmentsByDay.slice(-Number(range)).map(point => ({ + date: point.date, + formattedValue: formatNumber(point.count), + label: 'Runs', + value: point.count + })), [metrics.enrollmentsByDay, range]); + const chartMax = Math.max(...chartData.map(point => point.value), 1); + + return ( + + ); +}; + +export default RunAnalyticsSidebar; diff --git a/apps/admin/src/automations/editor.tsx b/apps/admin/src/automations/editor.tsx index 6a58c195e67..d450ceab2ac 100644 --- a/apps/admin/src/automations/editor.tsx +++ b/apps/admin/src/automations/editor.tsx @@ -1,5 +1,6 @@ import AutomationCanvas, {EMAIL_STEP_QUERY_PARAM} from './components/canvas/automation-canvas'; import AutomationHeader from './components/automation-header'; +import RunAnalyticsSidebar from './components/run-analytics-sidebar'; import {useAutomationForEditing} from './hooks/use-automation-for-editing'; import React from 'react'; import {AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Button, type ButtonProps, LoadingIndicator} from '@tryghost/shade/components'; @@ -418,31 +419,34 @@ const AutomationEditorContent: React.FC<{automationId: string}> = ({automationId onTurnOff={() => setEditState({phase: 'confirming', action: 'unpublish'})} /> - { - onEmailDirtyChange(false); - if (isBlockedEmailNavigationLeavingEditorRef.current) { +
+ {draft && } + { + onEmailDirtyChange(false); + if (isBlockedEmailNavigationLeavingEditorRef.current) { + isBlockedEmailNavigationLeavingEditorRef.current = false; + navigationBlocker.reset?.(); + closeEmailModal(); + return; + } + + isBlockedEmailNavigationLeavingEditorRef.current = false; + navigationBlocker.proceed?.(); + }} + onEmailDirtyChange={onEmailDirtyChange} + onKeepEditingAfterBlockedEmailNavigation={() => { isBlockedEmailNavigationLeavingEditorRef.current = false; navigationBlocker.reset?.(); - closeEmailModal(); - return; - } - - isBlockedEmailNavigationLeavingEditorRef.current = false; - navigationBlocker.proceed?.(); - }} - onEmailDirtyChange={onEmailDirtyChange} - onKeepEditingAfterBlockedEmailNavigation={() => { - isBlockedEmailNavigationLeavingEditorRef.current = false; - navigationBlocker.reset?.(); - }} - /> + }} + /> +
; + inProgress: number; + lastRunAt: string | null; +}; + +const analyticsBySlug: Record = { + 'member-welcome-email-free': { + completed: 1225, + enrollments: 1432, + enrollmentsByDay: [18, 20, 21, 23, 22, 25, 27, 26, 29, 31, 30, 32, 33, 34, 33, 35, 36, 35, 34, 33, 31, 32, 30, 29, 30, 28, 27, 28, 26, 27].map((count, index) => ({ + date: new Date(Date.UTC(2026, 6, index - 8)).toISOString().slice(0, 10), + count + })), + inProgress: 118, + lastRunAt: '2026-07-21T07:12:00Z' + }, + 'member-welcome-email-paid': { + completed: 320, + enrollments: 412, + enrollmentsByDay: [9, 10, 10, 11, 11, 12, 11, 12, 13, 12, 13, 13, 12, 13, 14, 13, 13, 12, 13, 13, 12, 13, 14, 13, 13, 12, 13, 14, 13, 13].map((count, index) => ({ + date: new Date(Date.UTC(2026, 6, index - 8)).toISOString().slice(0, 10), + count + })), + inProgress: 61, + lastRunAt: '2026-07-21T05:55:00Z' + } +}; + +const emptyAnalytics: AutomationRunAnalytics = { + completed: 0, + enrollments: 0, + enrollmentsByDay: [], + inProgress: 0, + lastRunAt: null +}; + +export const getAutomationRunAnalytics = (automation: Pick): AutomationRunAnalytics => ( + analyticsBySlug[automation.slug] ?? emptyAnalytics +); + +export const formatLastRun = (iso: string | null, now = Date.now()): string => { + if (!iso) { + return 'Never'; + } + + const minutes = Math.max(0, Math.round((now - new Date(iso).getTime()) / 60_000)); + if (minutes < 1) { + return 'Just now'; + } + if (minutes < 60) { + return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + } + + const hours = Math.round(minutes / 60); + if (hours < 24) { + return `${hours} hour${hours === 1 ? '' : 's'} ago`; + } + + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? '' : 's'} ago`; +}; From e5d25d6b264bee65550744dcab7043a6a5fe7163 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 10 Aug 2026 15:23:59 -0400 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20Wired=20automation=20run=20anal?= =?UTF-8?q?ytics=20from=20backend=20to=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automation tables and performance charts now reflect persisted run data instead of prototype fixtures. --- apps/admin-x-framework/src/api/automations.ts | 24 ++++ .../src/automations/automations.test.tsx | 7 +- apps/admin/src/automations/automations.tsx | 13 +- .../components/automations-list.test.tsx | 16 ++- .../components/automations-list.tsx | 24 ++-- .../components/run-analytics-sidebar.test.tsx | 19 +++ .../components/run-analytics-sidebar.tsx | 20 ++- apps/admin/src/automations/editor.test.tsx | 1 + apps/admin/src/automations/format-last-run.ts | 21 +++ apps/admin/src/automations/run-analytics.ts | 66 ---------- .../api/endpoints/automation-run-analytics.ts | 46 +++++++ ghost/core/core/server/api/endpoints/index.js | 4 + ...0-18-09-44-add-automation-run-lifecycle.js | 103 +++++++++++++++ ghost/core/core/server/data/schema/schema.js | 8 +- .../services/automations/automations-api.ts | 76 ++++++++++- .../automations/automations-repository.ts | 38 +++++- .../database-automations-repository.ts | 123 +++++++++++++++++- .../server/web/api/endpoints/admin/routes.js | 1 + ghost/core/package.json | 2 +- .../test/e2e-api/admin/automations.test.js | 47 +++++++ .../unit/server/data/schema/integrity.test.js | 2 +- .../automations-repository.test.ts | 73 ++++++++++- 22 files changed, 633 insertions(+), 101 deletions(-) create mode 100644 apps/admin/src/automations/format-last-run.ts delete mode 100644 apps/admin/src/automations/run-analytics.ts create mode 100644 ghost/core/core/server/api/endpoints/automation-run-analytics.ts create mode 100644 ghost/core/core/server/data/migrations/versions/6.58/2026-08-10-18-09-44-add-automation-run-lifecycle.js diff --git a/apps/admin-x-framework/src/api/automations.ts b/apps/admin-x-framework/src/api/automations.ts index 770f20a88cc..0782c382ca3 100644 --- a/apps/admin-x-framework/src/api/automations.ts +++ b/apps/admin-x-framework/src/api/automations.ts @@ -88,6 +88,25 @@ export type AutomationActionLinksResponseType = { automation_action_links: AutomationActionLink[]; } +export type AutomationRunAnalyticsPoint = { + date: string; + count: number; +} + +export type AutomationRunAnalytics = { + automation_id: string; + total_runs: number; + in_progress: number; + completed: number; + last_run_at: string | null; + runs_by_day?: AutomationRunAnalyticsPoint[]; +} + +export type AutomationRunAnalyticsResponseType = { + meta?: Meta; + automation_run_analytics: AutomationRunAnalytics[]; +} + const dataType = 'AutomationsResponseType'; export const useBrowseAutomations = createQuery({ @@ -95,6 +114,11 @@ export const useBrowseAutomations = createQuery({ path: '/automations/' }); +export const useBrowseAutomationRunAnalytics = createQuery({ + dataType: 'AutomationRunAnalyticsResponseType', + path: '/automations/run-analytics/' +}); + export const useReadAutomation = createQueryWithId({ dataType, path: id => `/automations/${id}/` diff --git a/apps/admin/src/automations/automations.test.tsx b/apps/admin/src/automations/automations.test.tsx index 0870285a397..c21591bd941 100644 --- a/apps/admin/src/automations/automations.test.tsx +++ b/apps/admin/src/automations/automations.test.tsx @@ -3,8 +3,9 @@ import {MemoryRouter} from 'react-router'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {render, screen} from '@testing-library/react'; -const {mockUseBrowseAutomations, mockUseBrowseSettings, mockUseBrowseConfig, mockUseCurrentUser} = vi.hoisted(() => ({ +const {mockUseBrowseAutomations, mockUseBrowseAutomationRunAnalytics, mockUseBrowseSettings, mockUseBrowseConfig, mockUseCurrentUser} = vi.hoisted(() => ({ mockUseBrowseAutomations: vi.fn(), + mockUseBrowseAutomationRunAnalytics: vi.fn(), mockUseBrowseSettings: vi.fn(), mockUseBrowseConfig: vi.fn(), mockUseCurrentUser: vi.fn() @@ -16,7 +17,8 @@ vi.mock('@tryghost/admin-x-framework/api/automations', async () => { ); return { ...actual, - useBrowseAutomations: mockUseBrowseAutomations + useBrowseAutomations: mockUseBrowseAutomations, + useBrowseAutomationRunAnalytics: mockUseBrowseAutomationRunAnalytics }; }); @@ -86,6 +88,7 @@ describe('Automations', () => { beforeEach(() => { vi.clearAllMocks(); mockUseBrowseAutomations.mockReturnValue({data: {automations}, isError: false, isLoading: false}); + mockUseBrowseAutomationRunAnalytics.mockReturnValue({data: {automation_run_analytics: []}, isError: false, isLoading: false}); mockUseBrowseSettings.mockReturnValue({data: stripeConnectedSettings, isLoading: false}); mockUseBrowseConfig.mockReturnValue({data: {config: {}}, isLoading: false}); mockUseCurrentUser.mockReturnValue({data: {id: 'user-1', roles: [{name: 'Owner'}]}}); diff --git a/apps/admin/src/automations/automations.tsx b/apps/admin/src/automations/automations.tsx index 1e48a183597..ebd38a87448 100644 --- a/apps/admin/src/automations/automations.tsx +++ b/apps/admin/src/automations/automations.tsx @@ -1,6 +1,7 @@ import AutomationsHelpCards from './components/automations-help-cards'; import AutomationsList from './components/automations-list'; import React from 'react'; +import {useBrowseAutomationRunAnalytics} from '@tryghost/admin-x-framework/api/automations'; import {Badge} from '@tryghost/shade/components'; import {Box, Container} from '@tryghost/shade/primitives'; import {ListPage} from '@tryghost/shade/page-templates'; @@ -9,9 +10,11 @@ import {useVisibleAutomations} from './hooks/use-visible-automations'; const Automations: React.FC = () => { const {automations, error, isError, isLoading} = useVisibleAutomations(); + const analyticsQuery = useBrowseAutomationRunAnalytics(); - if (isError) { - throw error instanceof Error ? error : new Error('Failed to load automations'); + if (isError || analyticsQuery.isError) { + const queryError = error || analyticsQuery.error; + throw queryError instanceof Error ? queryError : new Error('Failed to load automations'); } return ( @@ -31,7 +34,11 @@ const Automations: React.FC = () => { - + diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index a5a11858de1..179fece7848 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -16,11 +16,25 @@ const automations = [{ status: 'inactive' as const }]; +const analytics = [{ + automation_id: 'automation-id-1', + total_runs: 1432, + in_progress: 118, + completed: 1225, + last_run_at: '2026-07-21T07:12:00Z' +}, { + automation_id: 'automation-id-2', + total_runs: 412, + in_progress: 61, + completed: 320, + last_run_at: '2026-07-21T05:55:00Z' +}]; + const renderWithRouter = (ui: React.ReactElement) => render({ui}); describe('AutomationsList', () => { it('renders fetched automations with private beta copy and status labels', () => { - renderWithRouter(); + renderWithRouter(); expect(screen.getAllByTestId('automation-list-row')).toHaveLength(2); expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index 8907e853301..8a7126cd408 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -1,10 +1,10 @@ import AutomationStatusBadge from './automation-status-badge'; import React from 'react'; -import type {Automation} from '@tryghost/admin-x-framework/api/automations'; +import type {Automation, AutomationRunAnalytics} from '@tryghost/admin-x-framework/api/automations'; import {Link} from '@tryghost/admin-x-framework'; import {Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; import {cn, formatNumber} from '@tryghost/shade/utils'; -import {formatLastRun, getAutomationRunAnalytics} from '@/automations/run-analytics'; +import {formatLastRun} from '@/automations/format-last-run'; const AUTOMATION_DESCRIPTIONS: Record = { 'member-welcome-email-free': 'Welcome new free members after they sign up.', @@ -13,6 +13,7 @@ const AUTOMATION_DESCRIPTIONS: Record = { interface AutomationsListProps { automations?: Automation[]; + analytics?: AutomationRunAnalytics[]; isLoading?: boolean; } @@ -45,7 +46,7 @@ const AutomationsListSkeleton: React.FC = () => { ); }; -const AutomationsList: React.FC = ({automations = [], isLoading = false}) => { +const AutomationsList: React.FC = ({automations = [], analytics = [], isLoading = false}) => { if (isLoading) { return ; } @@ -64,7 +65,10 @@ const AutomationsList: React.FC = ({automations = [], isLo {automations.map((automation) => { const description = AUTOMATION_DESCRIPTIONS[automation.slug]; - const metrics = getAutomationRunAnalytics(automation); + const metrics = analytics.find(item => item.automation_id === automation.id); + const lastRunAt = metrics?.last_run_at ?? null; + const inProgress = metrics?.in_progress ?? 0; + const completed = metrics?.completed ?? 0; return ( = ({automations = [], isLo )} - - {formatLastRun(metrics.lastRunAt)} + + {formatLastRun(lastRunAt)} - - {formatNumber(metrics.inProgress)} + + {formatNumber(inProgress)} - - {formatNumber(metrics.completed)} + + {formatNumber(completed)} diff --git a/apps/admin/src/automations/components/run-analytics-sidebar.test.tsx b/apps/admin/src/automations/components/run-analytics-sidebar.test.tsx index 16bc1f6fd30..ec5301bfc2d 100644 --- a/apps/admin/src/automations/components/run-analytics-sidebar.test.tsx +++ b/apps/admin/src/automations/components/run-analytics-sidebar.test.tsx @@ -3,6 +3,25 @@ import type {GhAreaChartDataItem} from '@tryghost/shade/patterns'; import {describe, expect, it, vi} from 'vitest'; import {render, screen} from '@testing-library/react'; +vi.mock('@tryghost/admin-x-framework/api/automations', async () => { + const actual = await vi.importActual('@tryghost/admin-x-framework/api/automations'); + return { + ...actual, + useBrowseAutomationRunAnalytics: () => ({ + data: { + automation_run_analytics: [{ + automation_id: 'automation-id-1', + total_runs: 1432, + in_progress: 118, + completed: 1225, + last_run_at: '2026-07-21T07:12:00Z', + runs_by_day: Array.from({length: 30}, (_, index) => ({date: `2026-07-${index + 1}`, count: index})) + }] + } + }) + }; +}); + vi.mock('@tryghost/shade/patterns', () => ({ GhAreaChart: ({data}: {data: GhAreaChartDataItem[]}) =>
})); diff --git a/apps/admin/src/automations/components/run-analytics-sidebar.tsx b/apps/admin/src/automations/components/run-analytics-sidebar.tsx index 409e7e0bd25..43612b792a5 100644 --- a/apps/admin/src/automations/components/run-analytics-sidebar.tsx +++ b/apps/admin/src/automations/components/run-analytics-sidebar.tsx @@ -1,10 +1,10 @@ import React, {useMemo, useState} from 'react'; import type {Automation} from '@tryghost/admin-x-framework/api/automations'; +import {useBrowseAutomationRunAnalytics} from '@tryghost/admin-x-framework/api/automations'; import {MetricValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {Box, Grid, Inline, Stack, Text} from '@tryghost/shade/primitives'; import {GhAreaChart} from '@tryghost/shade/patterns'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; -import {getAutomationRunAnalytics} from '@/automations/run-analytics'; const MetricTile: React.FC<{label: string; value: number; color: string}> = ({label, value, color}) => ( @@ -22,13 +22,19 @@ const MetricTile: React.FC<{label: string; value: number; color: string}> = ({la const RunAnalyticsSidebar: React.FC<{automation: Automation}> = ({automation}) => { const [range, setRange] = useState('30'); - const metrics = getAutomationRunAnalytics(automation); - const chartData = useMemo(() => metrics.enrollmentsByDay.slice(-Number(range)).map(point => ({ + const {data} = useBrowseAutomationRunAnalytics({ + searchParams: { + automation_id: automation.id, + include: 'series' + } + }); + const metrics = data?.automation_run_analytics[0]; + const chartData = useMemo(() => (metrics?.runs_by_day ?? []).slice(-Number(range)).map(point => ({ date: point.date, formattedValue: formatNumber(point.count), label: 'Runs', value: point.count - })), [metrics.enrollmentsByDay, range]); + })), [metrics?.runs_by_day, range]); const chartMax = Math.max(...chartData.map(point => point.value), 1); return ( @@ -56,7 +62,7 @@ const RunAnalyticsSidebar: React.FC<{automation: Automation}> = ({automation}) = Total runs )} - value={formatNumber(metrics.enrollments)} + value={formatNumber(metrics?.total_runs ?? 0)} /> = ({automation}) = - - + + diff --git a/apps/admin/src/automations/editor.test.tsx b/apps/admin/src/automations/editor.test.tsx index 48bc3153b22..9332ad4c35a 100644 --- a/apps/admin/src/automations/editor.test.tsx +++ b/apps/admin/src/automations/editor.test.tsx @@ -140,6 +140,7 @@ vi.mock('@tryghost/admin-x-framework/api/automations', async () => { ...mockUseReadAutomation(...args) }), useBrowseAutomationActionLinks: (...args: unknown[]) => mockUseBrowseAutomationActionLinks(...args), + useBrowseAutomationRunAnalytics: () => ({data: {automation_run_analytics: []}}), useEditAutomation: () => mockEditMutation }; }); diff --git a/apps/admin/src/automations/format-last-run.ts b/apps/admin/src/automations/format-last-run.ts new file mode 100644 index 00000000000..1203d570c40 --- /dev/null +++ b/apps/admin/src/automations/format-last-run.ts @@ -0,0 +1,21 @@ +export const formatLastRun = (iso: string | null, now = Date.now()): string => { + if (!iso) { + return 'Never'; + } + + const minutes = Math.max(0, Math.round((now - new Date(iso).getTime()) / 60_000)); + if (minutes < 1) { + return 'Just now'; + } + if (minutes < 60) { + return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + } + + const hours = Math.round(minutes / 60); + if (hours < 24) { + return `${hours} hour${hours === 1 ? '' : 's'} ago`; + } + + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? '' : 's'} ago`; +}; diff --git a/apps/admin/src/automations/run-analytics.ts b/apps/admin/src/automations/run-analytics.ts deleted file mode 100644 index 98bc345b3dc..00000000000 --- a/apps/admin/src/automations/run-analytics.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type {Automation} from '@tryghost/admin-x-framework/api/automations'; - -export type AutomationRunAnalytics = { - completed: number; - enrollments: number; - enrollmentsByDay: Array<{date: string; count: number}>; - inProgress: number; - lastRunAt: string | null; -}; - -const analyticsBySlug: Record = { - 'member-welcome-email-free': { - completed: 1225, - enrollments: 1432, - enrollmentsByDay: [18, 20, 21, 23, 22, 25, 27, 26, 29, 31, 30, 32, 33, 34, 33, 35, 36, 35, 34, 33, 31, 32, 30, 29, 30, 28, 27, 28, 26, 27].map((count, index) => ({ - date: new Date(Date.UTC(2026, 6, index - 8)).toISOString().slice(0, 10), - count - })), - inProgress: 118, - lastRunAt: '2026-07-21T07:12:00Z' - }, - 'member-welcome-email-paid': { - completed: 320, - enrollments: 412, - enrollmentsByDay: [9, 10, 10, 11, 11, 12, 11, 12, 13, 12, 13, 13, 12, 13, 14, 13, 13, 12, 13, 13, 12, 13, 14, 13, 13, 12, 13, 14, 13, 13].map((count, index) => ({ - date: new Date(Date.UTC(2026, 6, index - 8)).toISOString().slice(0, 10), - count - })), - inProgress: 61, - lastRunAt: '2026-07-21T05:55:00Z' - } -}; - -const emptyAnalytics: AutomationRunAnalytics = { - completed: 0, - enrollments: 0, - enrollmentsByDay: [], - inProgress: 0, - lastRunAt: null -}; - -export const getAutomationRunAnalytics = (automation: Pick): AutomationRunAnalytics => ( - analyticsBySlug[automation.slug] ?? emptyAnalytics -); - -export const formatLastRun = (iso: string | null, now = Date.now()): string => { - if (!iso) { - return 'Never'; - } - - const minutes = Math.max(0, Math.round((now - new Date(iso).getTime()) / 60_000)); - if (minutes < 1) { - return 'Just now'; - } - if (minutes < 60) { - return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; - } - - const hours = Math.round(minutes / 60); - if (hours < 24) { - return `${hours} hour${hours === 1 ? '' : 's'} ago`; - } - - const days = Math.round(hours / 24); - return `${days} day${days === 1 ? '' : 's'} ago`; -}; diff --git a/ghost/core/core/server/api/endpoints/automation-run-analytics.ts b/ghost/core/core/server/api/endpoints/automation-run-analytics.ts new file mode 100644 index 00000000000..eae8026384f --- /dev/null +++ b/ghost/core/core/server/api/endpoints/automation-run-analytics.ts @@ -0,0 +1,46 @@ +import * as automationsApi from '../../services/automations/automations-api'; + +type BrowseFrame = { + options: { + automation_id?: string; + include?: 'series'; + date_from?: string; + date_to?: string; + withRelated?: string[]; + }; +}; + +const controller = { + docName: 'automation_run_analytics', + + browse: { + headers: { + cacheInvalidate: false + }, + options: [ + 'automation_id', + 'include', + 'date_from', + 'date_to' + ], + validation: { + options: { + include: { + values: ['series'] + } + } + }, + permissions: { + docName: 'automations', + method: 'browse' + }, + async query(frame: BrowseFrame) { + return await automationsApi.browseRunAnalytics({ + ...frame.options, + include: frame.options.withRelated?.includes('series') ? 'series' : undefined + }); + } + } +}; + +module.exports = controller; diff --git a/ghost/core/core/server/api/endpoints/index.js b/ghost/core/core/server/api/endpoints/index.js index 48d63adff79..e7beb58be0b 100644 --- a/ghost/core/core/server/api/endpoints/index.js +++ b/ghost/core/core/server/api/endpoints/index.js @@ -15,6 +15,10 @@ module.exports = { return apiFramework.pipeline(require('./automation-action-links'), localUtils); }, + get automationRunAnalytics() { + return apiFramework.pipeline(require('./automation-run-analytics'), localUtils); + }, + get automationEmailPreviews() { return apiFramework.pipeline(require('./automation-email-previews'), localUtils); }, diff --git a/ghost/core/core/server/data/migrations/versions/6.58/2026-08-10-18-09-44-add-automation-run-lifecycle.js b/ghost/core/core/server/data/migrations/versions/6.58/2026-08-10-18-09-44-add-automation-run-lifecycle.js new file mode 100644 index 00000000000..63a72953358 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.58/2026-08-10-18-09-44-add-automation-run-lifecycle.js @@ -0,0 +1,103 @@ +const logging = require('@tryghost/logging'); +const {createNonTransactionalMigration} = require('../../utils'); +const {addColumn, addIndex, dropColumn, dropIndex} = require('../../../schema/commands'); + +const TABLE = 'automation_runs'; +const STATUS_COLUMN = 'status'; +const FINISHED_AT_COLUMN = 'finished_at'; +const RUN_STATUSES = [ + 'in_progress', + 'completed', + 'automation disabled', + 'failed', + 'member changed status', + 'member unsubscribed' +]; +const TERMINAL_STEP_STATUSES = RUN_STATUSES.slice(2); + +const statusDefinition = { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'in_progress', + validations: {isIn: [RUN_STATUSES]} +}; + +const finishedAtDefinition = { + type: 'dateTime', + nullable: true +}; + +async function addColumnIfMissing(knex, column, definition) { + if (await knex.schema.hasColumn(TABLE, column)) { + logging.warn(`Adding column: ${TABLE}.${column} - skipping as column already exists`); + return; + } + + await addColumn(TABLE, column, knex, definition, {algorithm: 'auto'}); +} + +async function dropColumnIfPresent(knex, column, definition) { + if (!await knex.schema.hasColumn(TABLE, column)) { + logging.warn(`Removing column: ${TABLE}.${column} - skipping as column does not exist`); + return; + } + + await dropColumn(TABLE, column, knex, definition, {algorithm: 'auto'}); +} + +async function backfillRunStatuses(knex) { + // These updates intentionally run in order so a run with multiple terminal + // steps keeps the first matching lifecycle reason. + // eslint-disable-next-line no-restricted-syntax + for (const status of TERMINAL_STEP_STATUSES) { + const updated = await knex(TABLE) + .where(STATUS_COLUMN, 'in_progress') + .whereExists( + knex('automation_run_steps') + .select(knex.raw('1')) + .whereColumn('automation_run_steps.automation_run_id', `${TABLE}.id`) + .where('automation_run_steps.status', status) + ) + .update({[STATUS_COLUMN]: status}); + logging.info(`Backfilled ${updated} automation run(s) with status "${status}"`); + } + + const completed = await knex(TABLE) + .where(STATUS_COLUMN, 'in_progress') + .whereExists( + knex('automation_run_steps') + .select(knex.raw('1')) + .whereColumn('automation_run_steps.automation_run_id', `${TABLE}.id`) + ) + .whereNotExists( + knex('automation_run_steps') + .select(knex.raw('1')) + .whereColumn('automation_run_steps.automation_run_id', `${TABLE}.id`) + .whereNot('automation_run_steps.status', 'finished') + ) + .update({[STATUS_COLUMN]: 'completed'}); + logging.info(`Backfilled ${completed} completed automation run(s)`); + + const finished = await knex(TABLE) + .whereNot(STATUS_COLUMN, 'in_progress') + .whereNull(FINISHED_AT_COLUMN) + .update({[FINISHED_AT_COLUMN]: knex.raw('??', ['updated_at'])}); + logging.info(`Backfilled finished_at for ${finished} automation run(s)`); +} + +module.exports = createNonTransactionalMigration( + async function up(knex) { + await addColumnIfMissing(knex, STATUS_COLUMN, statusDefinition); + await addColumnIfMissing(knex, FINISHED_AT_COLUMN, finishedAtDefinition); + await backfillRunStatuses(knex); + await addIndex(TABLE, ['automation_id', 'created_at'], knex); + await addIndex(TABLE, ['automation_id', 'status', 'created_at'], knex); + }, + async function down(knex) { + await dropIndex(TABLE, ['automation_id', 'status', 'created_at'], knex); + await dropIndex(TABLE, ['automation_id', 'created_at'], knex); + await dropColumnIfPresent(knex, FINISHED_AT_COLUMN, finishedAtDefinition); + await dropColumnIfPresent(knex, STATUS_COLUMN, statusDefinition); + } +); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index cd723183e49..de3824874c1 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -1306,7 +1306,13 @@ module.exports = { updated_at: {type: 'dateTime', nullable: false}, automation_id: {type: 'string', maxlength: 24, nullable: false, references: 'automations.id', restrictDelete: true}, member_id: {type: 'string', maxlength: 24, nullable: true, references: 'members.id', setNullDelete: true, index: true}, - member_email: {type: 'string', maxlength: 191, nullable: false, validations: {isEmail: true}} + member_email: {type: 'string', maxlength: 191, nullable: false, validations: {isEmail: true}}, + status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'in_progress', validations: {isIn: [['in_progress', 'completed', 'automation disabled', 'failed', 'member changed status', 'member unsubscribed']]}}, + finished_at: {type: 'dateTime', nullable: true}, + '@@INDEXES@@': [ + ['automation_id', 'created_at'], + ['automation_id', 'status', 'created_at'] + ] }, automation_run_steps: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, diff --git a/ghost/core/core/server/services/automations/automations-api.ts b/ghost/core/core/server/services/automations/automations-api.ts index d88f664b212..bf9c4c74bf9 100644 --- a/ghost/core/core/server/services/automations/automations-api.ts +++ b/ghost/core/core/server/services/automations/automations-api.ts @@ -1,6 +1,7 @@ import errors from '@tryghost/errors'; import tpl from '@tryghost/tpl'; import ObjectId from 'bson-objectid'; +import moment from 'moment-timezone'; import {z} from 'zod'; import {createDatabaseAutomationsRepository} from './database-automations-repository'; import {parseFakeWaitHoursMultiplier} from './fake-wait-hours-multiplier'; @@ -14,6 +15,7 @@ const domainEvents = require('@tryghost/domain-events'); const labs = require('../../../shared/labs'); const config = require('../../../shared/config'); const lexicalLib = require('../../lib/lexical'); +const settingsCache = require('../../../shared/settings-cache'); const StartAutomationsPollEvent = require('./events/start-automations-poll-event'); const MAX_AUTOMATION_ACTIONS = 20; @@ -30,7 +32,9 @@ const messages = { invalidAutomationGraphShape: 'Automation graph must be a single linear path without branches or cycles.', emptyEmailSubjectWhenActive: 'Active automations require a subject line for every email.', emptyEmailBodyWhenActive: 'Active automations require a body for every email.', - invalidEmailLexical: 'Email lexical must be a well-formed Lexical document.' + invalidEmailLexical: 'Email lexical must be a well-formed Lexical document.', + invalidRunAnalyticsOptions: 'Invalid automation run analytics options.', + invalidRunAnalyticsDateRange: 'Automation run analytics date ranges must contain between 1 and 90 days.' }; const objectIdSchema = z.string().refine(value => ObjectId.isValid(value)); @@ -76,6 +80,76 @@ export async function browse() { return await repository.browse(); } +const runAnalyticsOptionsSchema = z.object({ + automation_id: objectIdSchema.optional(), + include: z.enum(['series']).optional(), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() +}).refine(options => Boolean(options.date_from) === Boolean(options.date_to)); + +export async function browseRunAnalytics(options: unknown) { + const result = runAnalyticsOptionsSchema.safeParse(options); + if (!result.success) { + throwValidationError(messages.invalidRunAnalyticsOptions); + } + + const timezone = settingsCache.get('timezone') || 'Etc/UTC'; + const dateBuckets = result.data.include === 'series' ? buildRunAnalyticsDateBuckets({ + dateFrom: result.data.date_from, + dateTo: result.data.date_to, + timezone + }) : undefined; + const data = await repository.browseRunAnalytics({ + automationId: result.data.automation_id, + dateBuckets + }); + + return { + data, + meta: { + pagination: { + page: 1, + pages: 1, + limit: 'all' as const, + total: data.length, + prev: null, + next: null + } + } + }; +} + +function buildRunAnalyticsDateBuckets({dateFrom, dateTo, timezone}: { + dateFrom?: string; + dateTo?: string; + timezone: string; +}) { + const end = dateTo + ? moment.tz(dateTo, 'YYYY-MM-DD', true, timezone) + : moment().tz(timezone).startOf('day'); + const start = dateFrom + ? moment.tz(dateFrom, 'YYYY-MM-DD', true, timezone) + : end.clone().subtract(29, 'days'); + + if (!start.isValid() || !end.isValid()) { + throwValidationError(messages.invalidRunAnalyticsOptions); + } + + const dayCount = end.diff(start, 'days') + 1; + if (dayCount < 1 || dayCount > 90) { + throwValidationError(messages.invalidRunAnalyticsDateRange); + } + + return Array.from({length: dayCount}, (_, index) => { + const bucketStart = start.clone().add(index, 'days'); + return { + date: bucketStart.format('YYYY-MM-DD'), + start: bucketStart.toDate(), + end: bucketStart.clone().add(1, 'day').toDate() + }; + }); +} + export async function read(automationId: string) { const automation = await repository.getById(automationId); diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts index 1d9051e44af..36116ef58cf 100644 --- a/ghost/core/core/server/services/automations/automations-repository.ts +++ b/ghost/core/core/server/services/automations/automations-repository.ts @@ -38,6 +38,39 @@ export interface AutomationActionLink { clicked_count: number; } +export type AutomationRunStatus = + | 'in_progress' + | 'completed' + | 'automation disabled' + | 'failed' + | 'member changed status' + | 'member unsubscribed'; + +export interface AutomationRunAnalyticsBucket { + date: string; + count: number; +} + +export interface AutomationRunAnalytics { + automation_id: string; + total_runs: number; + in_progress: number; + completed: number; + last_run_at: string | null; + runs_by_day?: AutomationRunAnalyticsBucket[]; +} + +export interface AutomationRunAnalyticsDateBucket { + date: string; + start: Date; + end: Date; +} + +export interface BrowseAutomationRunAnalyticsOptions { + automationId?: string; + dateBuckets?: AutomationRunAnalyticsDateBucket[]; +} + export interface SendEmailAction { id: string; type: 'send_email'; @@ -137,6 +170,7 @@ export type AutomationStepTerminalStatus = export interface AutomationsRepository { browse(): Promise>; + browseRunAnalytics(options: BrowseAutomationRunAnalyticsOptions): Promise; getById(id: string): Promise; getAutomationActionLinks(automationId: string, actionId: string): Promise; edit(id: string, data: EditAutomationData): Promise; @@ -175,7 +209,7 @@ export interface AutomationsRepository { * Returns whether the step was updated. */ markStepTerminal( - step: Pick, + step: Pick, status: AutomationStepTerminalStatus ): Promise; /** @@ -184,7 +218,7 @@ export interface AutomationsRepository { * Returns whether the step was updated. */ retryStep( - step: Pick, + step: Pick, retryAt: Readonly ): Promise; /** diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts index db184a2529d..34b5c2132b1 100644 --- a/ghost/core/core/server/services/automations/database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/database-automations-repository.ts @@ -14,6 +14,8 @@ import type { AutomationAction, AutomationEdge, AutomationEmailStats, + AutomationRunAnalytics, + AutomationRunStatus, AutomationSummary, AutomationStepTerminalStatus, AutomationStepToRun, @@ -52,6 +54,20 @@ interface AutomationRow { updated_at: DatabaseDate; } +interface AutomationRunAnalyticsRow { + automation_id: string; + total_runs: string | number; + in_progress: string | number; + completed: string | number; + last_run_at: Date | string | null; +} + +interface AutomationRunAnalyticsSeriesRow { + automation_id: string; + date: string; + count: string | number; +} + interface ActionRow { id: string; @@ -158,6 +174,62 @@ export function createDatabaseAutomationsRepository({ }); }, + async browseRunAnalytics({automationId, dateBuckets}): Promise { + const summaryQuery = knex('automations as automation') + .select('automation.id as automation_id') + .select(knex.raw('COUNT(run.id) as total_runs')) + .select(knex.raw("SUM(CASE WHEN run.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress")) + .select(knex.raw("SUM(CASE WHEN run.status = 'completed' THEN 1 ELSE 0 END) as completed")) + .select(knex.raw('MAX(run.created_at) as last_run_at')) + .leftJoin('automation_runs as run', 'run.automation_id', 'automation.id') + .groupBy('automation.id') + .orderBy('automation.created_at', 'asc'); + + if (automationId) { + summaryQuery.where('automation.id', automationId); + } + + const summaries = await summaryQuery; + const analytics = summaries.map(row => ({ + automation_id: row.automation_id, + total_runs: Number(row.total_runs), + in_progress: Number(row.in_progress), + completed: Number(row.completed), + last_run_at: row.last_run_at ? new Date(row.last_run_at).toISOString() : null + })); + + if (!dateBuckets?.length || analytics.length === 0) { + return analytics; + } + + const caseSql = dateBuckets + .map(() => 'WHEN run.created_at >= ? AND run.created_at < ? THEN ?') + .join(' '); + const caseBindings = dateBuckets.flatMap(bucket => [ + bucket.start, + bucket.end, + bucket.date + ]); + const seriesQuery = knex('automation_runs as run') + .select('run.automation_id') + .select(knex.raw(`CASE ${caseSql} END as date`, caseBindings)) + .count({count: 'run.id'}) + .where('run.created_at', '>=', dateBuckets[0].start) + .where('run.created_at', '<', dateBuckets[dateBuckets.length - 1].end) + .whereIn('run.automation_id', analytics.map(item => item.automation_id)) + .groupBy('run.automation_id', 'date'); + const series = await seriesQuery as AutomationRunAnalyticsSeriesRow[]; + const counts = new Map(series.map(row => [`${row.automation_id}:${row.date}`, Number(row.count)])); + + return analytics.map(item => ({ + ...item, + runs_by_day: dateBuckets.map(bucket => ({ + date: bucket.date, + count: counts.get(`${item.automation_id}:${bucket.date}`) ?? 0 + })) + })); + }, + async getById(id: string): Promise { return await knex.transaction(async (trx) => { const automation = await loadAutomation(trx, id); @@ -560,7 +632,9 @@ async function trigger(trx: Knex.Transaction, options: Readonly<{ updated_at: nowString, automation_id: firstAction.automation_id, member_id: memberId, - member_email: memberEmail + member_email: memberEmail, + status: 'in_progress' satisfies AutomationRunStatus, + finished_at: null }; await trx('automation_runs').insert(run); @@ -801,12 +875,14 @@ async function finishStepAndEnqueueNext( } if (!await isRunAutomationActive(trx, step.automation_run_id)) { + await updateRunStatus(trx, step.automation_run_id, 'automation disabled'); return null; } const next = await findNextActionRevision(trx, step.action_id); if (!next) { + await updateRunStatus(trx, step.automation_run_id, 'completed'); return null; } @@ -847,20 +923,26 @@ async function findNextActionRevision(trx: Knex.Transaction, sourceActionId: str async function markStepTerminal( trx: Knex.Transaction, - step: Pick, + step: Pick, status: AutomationStepTerminalStatus ): Promise { const nowString = toDatabaseDate(new Date()); - return await updateStep(trx, step, { + const didUpdate = await updateStep(trx, step, { status, finished_at: nowString, updated_at: nowString }); + + if (didUpdate && status !== 'finished') { + await updateRunStatus(trx, step.automation_run_id, status); + } + + return didUpdate; } async function retryStep( trx: Knex.Transaction, - step: Pick, + step: Pick, retryAt: Readonly ): Promise { if (!await isStepRunAutomationActive(trx, step.id)) { @@ -927,6 +1009,39 @@ async function cancelCancelablePendingStepsForAutomation( .whereNull('locked_by') .orWhere('locked_at', '<', staleLockCutoff); }); + + await trx('automation_runs') + .update({ + status: 'automation disabled', + finished_at: nowString, + updated_at: nowString + }) + .where({ + automation_id: automationId, + status: 'in_progress' + }) + .whereExists(trx('automation_run_steps') + .select(trx.raw('1')) + .whereRaw('?? = ??', ['automation_run_steps.automation_run_id', 'automation_runs.id']) + .where('automation_run_steps.status', 'automation disabled')); +} + +async function updateRunStatus( + trx: Knex.Transaction, + runId: string, + status: Exclude +): Promise { + const nowString = toDatabaseDate(new Date()); + await trx('automation_runs') + .where({ + id: runId, + status: 'in_progress' + }) + .update({ + status, + finished_at: nowString, + updated_at: nowString + }); } async function isStepRunAutomationActive(trx: Knex.Transaction, stepId: string): Promise { diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index b57a10c687d..f11d4333721 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -208,6 +208,7 @@ module.exports = function apiRoutes() { // ## Automations router.get('/automations', mw.authAdminApi, http(api.automations.browse)); + router.get('/automations/run-analytics', mw.authAdminApi, http(api.automationRunAnalytics.browse)); router.get('/automations/:automation_id/actions/:action_id/links', mw.authAdminApi, http(api.automationActionLinks.browse)); router.get('/automations/:id', mw.authAdminApi, http(api.automations.read)); router.post('/automations/:id/email_preview', mw.authAdminApi, http(api.automationEmailPreviews.preview)); diff --git a/ghost/core/package.json b/ghost/core/package.json index e8ff42b3222..3c5c62089d7 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.57.1-rc.0", + "version": "6.58.0-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", diff --git a/ghost/core/test/e2e-api/admin/automations.test.js b/ghost/core/test/e2e-api/admin/automations.test.js index 49fbf9bb92a..bd00a59aeee 100644 --- a/ghost/core/test/e2e-api/admin/automations.test.js +++ b/ghost/core/test/e2e-api/admin/automations.test.js @@ -242,6 +242,53 @@ describe('Automations API', function () { }); }); + describe('run analytics', function () { + it('returns run totals and an optional daily series', async function () { + const {body} = await agent.get('automations').expectStatus(200); + const automationId = body.automations[0].id; + const member = fixtureManager.get('members', 0); + await models.Base.knex('automation_runs').insert([{ + id: ObjectId().toHexString(), + automation_id: automationId, + member_id: member.id, + member_email: member.email, + status: 'completed', + finished_at: new Date('2026-08-08T13:00:00.000Z'), + created_at: new Date('2026-08-08T12:00:00.000Z'), + updated_at: new Date('2026-08-08T13:00:00.000Z') + }, { + id: ObjectId().toHexString(), + automation_id: automationId, + member_id: member.id, + member_email: member.email, + status: 'in_progress', + finished_at: null, + created_at: new Date('2026-08-10T12:00:00.000Z'), + updated_at: new Date('2026-08-10T12:00:00.000Z') + }]); + + const {body: analyticsBody} = await agent + .get(`automations/run-analytics/?automation_id=${automationId}&include=series&date_from=2026-08-08&date_to=2026-08-10`) + .expectStatus(200) + .expect(cacheInvalidateHeaderNotSet()); + + assert.deepEqual(analyticsBody.automation_run_analytics, [{ + automation_id: automationId, + total_runs: 2, + in_progress: 1, + completed: 1, + last_run_at: '2026-08-10T12:00:00.000Z', + runs_by_day: [{date: '2026-08-08', count: 1}, {date: '2026-08-09', count: 0}, {date: '2026-08-10', count: 1}] + }]); + }); + + it('rejects daily series ranges longer than 90 days', async function () { + await agent + .get('automations/run-analytics/?include=series&date_from=2026-01-01&date_to=2026-08-10') + .expectStatus(422); + }); + }); + describe('read', function () { it('returns the automation, ordered actions, and edges sourced from the database', async function () { const {body: browseBody} = await agent diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index 285ec5da876..81bedb0ed70 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = 'f5780f506a6a4f2597e48ef2c267ce72'; + const currentSchemaHash = '016fe84500fda76d57b403c1b5c1afaa'; const currentFixturesHash = '4e0c7b4fe3c1593e9d1fae1a891389ca'; const currentSettingsHash = '8650db85b9a61afe4797ad6333066c62'; const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1'; diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts index 2597c181d12..8e4d4671aab 100644 --- a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -133,6 +133,8 @@ const createDatabase = async (): Promise => { table.text('automation_id').notNullable().references('id').inTable('automations'); table.text('member_id'); // not a real foreign key here table.text('member_email').notNullable(); + table.text('status').notNullable().defaultTo('in_progress'); + table.text('finished_at'); }); await database.schema.createTable('automation_run_steps', (table) => { @@ -458,7 +460,7 @@ describe('automations repository', function () { return result; }; - const insertRun = async (automationId: string) => { + const insertRun = async (automationId: string, attrs: Record = {}) => { const now = toDatabaseDate(new Date()); const run = { id: ObjectId().toHexString(), @@ -466,7 +468,10 @@ describe('automations repository', function () { updated_at: now, automation_id: automationId, member_id: ObjectId().toHexString(), - member_email: 'member@example.com' + member_email: 'member@example.com', + status: 'in_progress', + finished_at: null, + ...attrs }; await knex('automation_runs').insert(run); @@ -719,6 +724,61 @@ describe('automations repository', function () { }); }); + describe('browseRunAnalytics', function () { + it('returns summaries for every automation, including those without runs', async function () { + const automation = await getAutomationBySlug('member-welcome-email-free'); + const firstRunAt = new Date('2026-08-08T12:00:00.000Z'); + const lastRunAt = new Date('2026-08-09T12:00:00.000Z'); + await insertRun(automation.id, { + created_at: toDatabaseDate(firstRunAt), + status: 'completed', + finished_at: toDatabaseDate(firstRunAt) + }); + await insertRun(automation.id, { + created_at: toDatabaseDate(lastRunAt), + status: 'in_progress' + }); + + const result = await repo.browseRunAnalytics({}); + const free = result.find(item => item.automation_id === automation.id); + assert.deepEqual(free, { + automation_id: automation.id, + total_runs: 2, + in_progress: 1, + completed: 1, + last_run_at: toRepositoryDateISOString(lastRunAt) + }); + assert.equal(result.length, 2); + assert(result.some(item => item.automation_id !== automation.id && item.total_runs === 0)); + }); + + it('returns a zero-filled daily series for one automation', async function () { + const automation = await getAutomationBySlug('member-welcome-email-free'); + await insertRun(automation.id, {created_at: '2026-08-08 12:00:00'}); + await insertRun(automation.id, {created_at: '2026-08-08 18:00:00'}); + await insertRun(automation.id, {created_at: '2026-08-10 08:00:00'}); + + const result = await repo.browseRunAnalytics({ + automationId: automation.id, + dateBuckets: [{ + date: '2026-08-08', + start: new Date('2026-08-08T00:00:00.000Z'), + end: new Date('2026-08-09T00:00:00.000Z') + }, { + date: '2026-08-09', + start: new Date('2026-08-09T00:00:00.000Z'), + end: new Date('2026-08-10T00:00:00.000Z') + }, { + date: '2026-08-10', + start: new Date('2026-08-10T00:00:00.000Z'), + end: new Date('2026-08-11T00:00:00.000Z') + }] + }); + + assert.deepEqual(result[0].runs_by_day, [{date: '2026-08-08', count: 2}, {date: '2026-08-09', count: 0}, {date: '2026-08-10', count: 1}]); + }); + }); + describe('URL serialization', function () { it('returns stored transform-ready email URLs as absolute URLs', async function () { const automation = await getAutomationBySlug('member-welcome-email-free'); @@ -1797,6 +1857,9 @@ describe('automations repository', function () { const allSteps = await getStepsByRunId(run.id); assert.equal(allSteps.length, 1); + const disabledRun = await knex('automation_runs').where('id', run.id).first(); + assert.equal(disabledRun.status, 'automation disabled'); + assert.equal(typeof disabledRun.finished_at, 'string'); }); it('does not finish or enqueue if the step lock has been taken by another runner', async function () { @@ -1851,6 +1914,9 @@ describe('automations repository', function () { const allSteps = await getStepsByRunId(run.id); assert.equal(allSteps.length, 1); + const completedRun = await knex('automation_runs').where('id', run.id).first(); + assert.equal(completedRun.status, 'completed'); + assert.equal(typeof completedRun.finished_at, 'string'); }); it('enqueues the latest revision of the next action', async function () { @@ -1929,6 +1995,9 @@ describe('automations repository', function () { assert(typeof markedFinishedAt === 'string'); assert(markedFinishedAt >= toDatabaseDate(new Date(beforeMark - 1000))); assert(markedFinishedAt <= toDatabaseDate(new Date(afterMark))); + const markedRun = await knex('automation_runs').where('id', run.id).first(); + assert.equal(markedRun.status, 'member unsubscribed'); + assert.equal(typeof markedRun.finished_at, 'string'); }); it('does not overwrite a step that is no longer pending', async function () { From 57de451d35fe388ec98363f1989949cc71dca2c6 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 10 Aug 2026 15:34:44 -0400 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20Added=20a=20feature=20flag=20fo?= =?UTF-8?q?r=20automation=20run=20analytics=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no ref The run analytics interface is still under development, so keeping the list columns and editor sidebar behind automationRunAnalytics allows the API to remain available while controlling exposure in Admin. --- .../src/automations/automations.test.tsx | 18 +++++ apps/admin/src/automations/automations.tsx | 9 ++- .../components/automations-list.test.tsx | 11 ++- .../components/automations-list.tsx | 70 +++++++++++-------- apps/admin/src/automations/editor.test.tsx | 21 ++++++ apps/admin/src/automations/editor.tsx | 9 ++- 6 files changed, 103 insertions(+), 35 deletions(-) diff --git a/apps/admin/src/automations/automations.test.tsx b/apps/admin/src/automations/automations.test.tsx index c21591bd941..c9b160ac124 100644 --- a/apps/admin/src/automations/automations.test.tsx +++ b/apps/admin/src/automations/automations.test.tsx @@ -3,6 +3,12 @@ import {MemoryRouter} from 'react-router'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {render, screen} from '@testing-library/react'; +const mockRunAnalyticsFlag = vi.hoisted(() => ({enabled: false})); + +vi.mock('@/settings/app/hooks/use-feature-flag', () => ({ + default: () => mockRunAnalyticsFlag.enabled +})); + const {mockUseBrowseAutomations, mockUseBrowseAutomationRunAnalytics, mockUseBrowseSettings, mockUseBrowseConfig, mockUseCurrentUser} = vi.hoisted(() => ({ mockUseBrowseAutomations: vi.fn(), mockUseBrowseAutomationRunAnalytics: vi.fn(), @@ -87,6 +93,7 @@ const renderPage = () => render(); describe('Automations', () => { beforeEach(() => { vi.clearAllMocks(); + mockRunAnalyticsFlag.enabled = false; mockUseBrowseAutomations.mockReturnValue({data: {automations}, isError: false, isLoading: false}); mockUseBrowseAutomationRunAnalytics.mockReturnValue({data: {automation_run_analytics: []}, isError: false, isLoading: false}); mockUseBrowseSettings.mockReturnValue({data: stripeConnectedSettings, isLoading: false}); @@ -99,6 +106,17 @@ describe('Automations', () => { expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); expect(screen.getByText('Paid member welcome flow')).toBeInTheDocument(); + expect(mockUseBrowseAutomationRunAnalytics).toHaveBeenCalledWith({enabled: false}); + expect(screen.queryByRole('columnheader', {name: 'Last run'})).not.toBeInTheDocument(); + }); + + it('loads and shows run analytics when the feature is enabled', () => { + mockRunAnalyticsFlag.enabled = true; + + renderPage(); + + expect(mockUseBrowseAutomationRunAnalytics).toHaveBeenCalledWith({enabled: true}); + expect(screen.getByRole('columnheader', {name: 'Last run'})).toBeInTheDocument(); }); it('hides the paid sequence when Stripe is not connected', () => { diff --git a/apps/admin/src/automations/automations.tsx b/apps/admin/src/automations/automations.tsx index ebd38a87448..d07c0963776 100644 --- a/apps/admin/src/automations/automations.tsx +++ b/apps/admin/src/automations/automations.tsx @@ -7,12 +7,14 @@ import {Box, Container} from '@tryghost/shade/primitives'; import {ListPage} from '@tryghost/shade/page-templates'; import {PageHeader} from '@tryghost/shade/patterns'; import {useVisibleAutomations} from './hooks/use-visible-automations'; +import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; const Automations: React.FC = () => { const {automations, error, isError, isLoading} = useVisibleAutomations(); - const analyticsQuery = useBrowseAutomationRunAnalytics(); + const runAnalyticsEnabled = useFeatureFlag('automationRunAnalytics'); + const analyticsQuery = useBrowseAutomationRunAnalytics({enabled: runAnalyticsEnabled}); - if (isError || analyticsQuery.isError) { + if (isError || (runAnalyticsEnabled && analyticsQuery.isError)) { const queryError = error || analyticsQuery.error; throw queryError instanceof Error ? queryError : new Error('Failed to load automations'); } @@ -37,7 +39,8 @@ const Automations: React.FC = () => { diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index 179fece7848..0ec65c40c3f 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -34,7 +34,7 @@ const renderWithRouter = (ui: React.ReactElement) => render({ui} { it('renders fetched automations with private beta copy and status labels', () => { - renderWithRouter(); + renderWithRouter(); expect(screen.getAllByTestId('automation-list-row')).toHaveLength(2); expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); @@ -52,6 +52,15 @@ describe('AutomationsList', () => { expect(screen.getByText('320')).toBeInTheDocument(); }); + it('keeps run analytics columns hidden when the feature is disabled', () => { + renderWithRouter(); + + expect(screen.queryByRole('columnheader', {name: 'Last run'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'In progress'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'Completed'})).not.toBeInTheDocument(); + expect(screen.queryByText('1,225')).not.toBeInTheDocument(); + }); + it('links each row to the automation sequence by id', () => { renderWithRouter(); diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index 8a7126cd408..12a09bfeb74 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -15,11 +15,15 @@ interface AutomationsListProps { automations?: Automation[]; analytics?: AutomationRunAnalytics[]; isLoading?: boolean; + showRunAnalytics?: boolean; } -const gridColumns = 'grid grid-cols-[1fr_auto] lg:grid-cols-[minmax(0,1fr)_170px_130px_130px_110px]'; +const getGridColumns = (showRunAnalytics: boolean) => cn( + 'grid grid-cols-[1fr_auto]', + showRunAnalytics && 'lg:grid-cols-[minmax(0,1fr)_170px_130px_130px_110px]' +); -const AutomationsListSkeleton: React.FC = () => { +const AutomationsListSkeleton: React.FC<{showRunAnalytics: boolean}> = ({showRunAnalytics}) => { return (
@@ -27,15 +31,19 @@ const AutomationsListSkeleton: React.FC = () => {
- - - Name - Last run - In progress - Completed - Status - - + {showRunAnalytics && ( + + + Name + Last run + In progress + Completed + Status + + + )} {automations.map((automation) => { const description = AUTOMATION_DESCRIPTIONS[automation.slug]; @@ -73,7 +83,7 @@ const AutomationsList: React.FC = ({automations = [], anal return ( @@ -91,16 +101,20 @@ const AutomationsList: React.FC = ({automations = [], anal )} - - {formatLastRun(lastRunAt)} - - - {formatNumber(inProgress)} - - - {formatNumber(completed)} - - + {showRunAnalytics && ( + <> + + {formatLastRun(lastRunAt)} + + + {formatNumber(inProgress)} + + + {formatNumber(completed)} + + + )} + diff --git a/apps/admin/src/automations/editor.test.tsx b/apps/admin/src/automations/editor.test.tsx index 9332ad4c35a..df6571ef7d1 100644 --- a/apps/admin/src/automations/editor.test.tsx +++ b/apps/admin/src/automations/editor.test.tsx @@ -147,6 +147,10 @@ vi.mock('@tryghost/admin-x-framework/api/automations', async () => { const mockLabs = vi.hoisted((): {current: Record} => ({current: {}})); +vi.mock('@/settings/app/hooks/use-feature-flag', () => ({ + default: (flag: string) => mockLabs.current[flag] ?? false +})); + vi.mock('@tryghost/admin-x-framework/api/config', async () => { const actual = await vi.importActual( '@tryghost/admin-x-framework/api/config' @@ -380,6 +384,23 @@ describe('AutomationEditor', () => { expect(screen.getByRole('button', {name: 'Publish'})).toBeDisabled(); }); + it('hides run analytics when the feature is disabled', () => { + mockAutomationWithEmailStats(); + + renderEditor(); + + expect(screen.queryByTestId('run-analytics-sidebar')).not.toBeInTheDocument(); + }); + + it('shows run analytics when the feature is enabled', () => { + mockLabs.current = {automationRunAnalytics: true}; + mockAutomationWithEmailStats(); + + renderEditor(); + + expect(screen.getByTestId('run-analytics-sidebar')).toBeInTheDocument(); + }); + it('waits for a fresh response before cached automation data becomes editable', async () => { const cachedAutomation = withEmailSubject(automationDetail, 'Cached subject'); mockUseReadAutomation.mockReturnValue({ diff --git a/apps/admin/src/automations/editor.tsx b/apps/admin/src/automations/editor.tsx index d450ceab2ac..537d0403b56 100644 --- a/apps/admin/src/automations/editor.tsx +++ b/apps/admin/src/automations/editor.tsx @@ -4,6 +4,7 @@ import RunAnalyticsSidebar from './components/run-analytics-sidebar'; import {useAutomationForEditing} from './hooks/use-automation-for-editing'; import React from 'react'; import {AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Button, type ButtonProps, LoadingIndicator} from '@tryghost/shade/components'; +import {Inline} from '@tryghost/shade/primitives'; import {useEditAutomation} from '@tryghost/admin-x-framework/api/automations'; import type {AutomationDetail, AutomationStatus} from '@tryghost/admin-x-framework/api/automations'; import {dequal} from 'dequal'; @@ -12,6 +13,7 @@ import {toast} from 'sonner'; import {useBlocker} from 'react-router'; import {useConfirmUnload, useParams} from '@tryghost/admin-x-framework'; import type {AutomationEditState} from './types'; +import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; const SUBJECT_REQUIRED_MESSAGE = 'Add a subject line.'; const BODY_REQUIRED_MESSAGE = 'Add an email body.'; @@ -52,6 +54,7 @@ const getActionErrors = (automation: AutomationDetail): Record = const AutomationEditorContent: React.FC<{automationId: string}> = ({automationId}) => { const {automation, isError: isReadError} = useAutomationForEditing(automationId); + const runAnalyticsEnabled = useFeatureFlag('automationRunAnalytics'); const editMutation = useEditAutomation(); const [editState, setEditState] = React.useState({phase: 'idle'}); @@ -419,8 +422,8 @@ const AutomationEditorContent: React.FC<{automationId: string}> = ({automationId onTurnOff={() => setEditState({phase: 'confirming', action: 'unpublish'})} /> -
- {draft && } + + {runAnalyticsEnabled && draft && } = ({automationId navigationBlocker.reset?.(); }} /> -
+ Date: Mon, 10 Aug 2026 14:05:10 -0700 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=8E=A8=20Cleaned=20up=20the=20automat?= =?UTF-8?q?ions=20list=20table=20header=20and=20column=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/NY-1498 - removed the redundant border between the page header and the table - centered the column header labels to match the header rhythm used on Tags and Members; the grid-based table loses the native table's vertical-align, so the labels were sitting top-aligned - relabeled "Last run" to "Last entry" to make clear it reflects the last entry into the automation rather than the last action taken, and updated the column-header test assertions to match --- apps/admin/src/automations/automations.test.tsx | 4 ++-- .../components/automations-list.test.tsx | 12 ++++++------ .../automations/components/automations-list.tsx | 14 +++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/admin/src/automations/automations.test.tsx b/apps/admin/src/automations/automations.test.tsx index c9b160ac124..dad423230a9 100644 --- a/apps/admin/src/automations/automations.test.tsx +++ b/apps/admin/src/automations/automations.test.tsx @@ -107,7 +107,7 @@ describe('Automations', () => { expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); expect(screen.getByText('Paid member welcome flow')).toBeInTheDocument(); expect(mockUseBrowseAutomationRunAnalytics).toHaveBeenCalledWith({enabled: false}); - expect(screen.queryByRole('columnheader', {name: 'Last run'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'Last entry'})).not.toBeInTheDocument(); }); it('loads and shows run analytics when the feature is enabled', () => { @@ -116,7 +116,7 @@ describe('Automations', () => { renderPage(); expect(mockUseBrowseAutomationRunAnalytics).toHaveBeenCalledWith({enabled: true}); - expect(screen.getByRole('columnheader', {name: 'Last run'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Last entry'})).toBeInTheDocument(); }); it('hides the paid sequence when Stripe is not connected', () => { diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index 0ec65c40c3f..cd2687e1032 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -43,9 +43,9 @@ describe('AutomationsList', () => { expect(screen.getByText('Welcome new paid members after they start their subscription.')).toBeInTheDocument(); expect(screen.getByText('Live')).toBeInTheDocument(); expect(screen.getByText('Off')).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Last run'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'In progress'})).toBeInTheDocument(); - expect(screen.getByRole('columnheader', {name: 'Completed'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Last entry'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Running'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Done'})).toBeInTheDocument(); expect(screen.getByText('118')).toBeInTheDocument(); expect(screen.getByText('1,225')).toBeInTheDocument(); expect(screen.getByText('61')).toBeInTheDocument(); @@ -55,9 +55,9 @@ describe('AutomationsList', () => { it('keeps run analytics columns hidden when the feature is disabled', () => { renderWithRouter(); - expect(screen.queryByRole('columnheader', {name: 'Last run'})).not.toBeInTheDocument(); - expect(screen.queryByRole('columnheader', {name: 'In progress'})).not.toBeInTheDocument(); - expect(screen.queryByRole('columnheader', {name: 'Completed'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'Last entry'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'Running'})).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', {name: 'Done'})).not.toBeInTheDocument(); expect(screen.queryByText('1,225')).not.toBeInTheDocument(); }); diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index 12a09bfeb74..8c6e3187bd9 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -25,7 +25,7 @@ const getGridColumns = (showRunAnalytics: boolean) => cn( const AutomationsListSkeleton: React.FC<{showRunAnalytics: boolean}> = ({showRunAnalytics}) => { return ( -
+
{Array.from({length: 2}, (_, index) => ( = ({automations = [], anal } return ( -
+
{showRunAnalytics && ( - Name - Last run - In progress - Completed - Status + Name + Last entry + Running + Done + Status )} From 1fc05b267acbf2ebd51035611405384ca1201d80 Mon Sep 17 00:00:00 2001 From: Zach Acuna Date: Mon, 10 Aug 2026 14:05:28 -0700 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20the=20automation=20e?= =?UTF-8?q?ditor=20canvas=20and=20side=20panel=20collapsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/NY-1498 - the canvas and side-panel row uses Shade's Inline, which defaults to align="center", so the React Flow canvas collapsed to zero height and never rendered its nodes, and the run analytics side panel did not fill the row height - set align="stretch" so both children fill the available height --- apps/admin/src/automations/editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/admin/src/automations/editor.tsx b/apps/admin/src/automations/editor.tsx index 537d0403b56..4464b462a09 100644 --- a/apps/admin/src/automations/editor.tsx +++ b/apps/admin/src/automations/editor.tsx @@ -422,7 +422,7 @@ const AutomationEditorContent: React.FC<{automationId: string}> = ({automationId onTurnOff={() => setEditState({phase: 'confirming', action: 'unpublish'})} /> - + {runAnalyticsEnabled && draft && } Date: Mon, 10 Aug 2026 14:29:28 -0700 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=8E=A8=20Cleaned=20up=20the=20automat?= =?UTF-8?q?ion=20run=20analytics=20metrics=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/NY-1498 - narrowed the panel to 400px - replaced the hand-rolled card boxes with Shade Card/CardContent - set the panel to bg-surface-elevated and made the cards fill-less (bg-transparent) so they no longer read darker than the panel in dark mode - matched the metric card padding to the analytics KPI cards (px-6 py-5) - matched the timeframe dropdown to the analytics date-range select: leading calendar icon, snug width, and grouped "Period" options --- .../components/run-analytics-sidebar.tsx | 90 ++++++++++--------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/apps/admin/src/automations/components/run-analytics-sidebar.tsx b/apps/admin/src/automations/components/run-analytics-sidebar.tsx index 43612b792a5..eb04164f8d8 100644 --- a/apps/admin/src/automations/components/run-analytics-sidebar.tsx +++ b/apps/admin/src/automations/components/run-analytics-sidebar.tsx @@ -1,23 +1,25 @@ import React, {useMemo, useState} from 'react'; import type {Automation} from '@tryghost/admin-x-framework/api/automations'; import {useBrowseAutomationRunAnalytics} from '@tryghost/admin-x-framework/api/automations'; -import {MetricValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; -import {Box, Grid, Inline, Stack, Text} from '@tryghost/shade/primitives'; +import {Card, CardContent, MetricValue, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue} from '@tryghost/shade/components'; +import {Grid, Inline, Stack, Text} from '@tryghost/shade/primitives'; import {GhAreaChart} from '@tryghost/shade/patterns'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; const MetricTile: React.FC<{label: string; value: number; color: string}> = ({label, value, color}) => ( - - - - {label} - - )} - value={formatNumber(value)} - /> - + + + + + {label} + + )} + value={formatNumber(value)} + /> + + ); const RunAnalyticsSidebar: React.FC<{automation: Automation}> = ({automation}) => { @@ -38,43 +40,49 @@ const RunAnalyticsSidebar: React.FC<{automation: Automation}> = ({automation}) = const chartMax = Math.max(...chartData.map(point => point.value), 1); return ( -