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
6 changes: 6 additions & 0 deletions .changeset/brown-rules-cut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tryghost/adapter-base-scheduling": patch
"@tryghost/adapter-base-sso": patch
---

Bumped framework dependencies
2 changes: 1 addition & 1 deletion apps/admin/src/automations/automations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const Automations: React.FC = () => {
}

return (
<Box className='size-full'>
<Box className='size-full' data-sentry-mask='true'>
<Container className='relative flex h-full flex-col' size='page'>
<ListPage data-testid="automations-page">
<ListPage.Header>
Expand Down
46 changes: 44 additions & 2 deletions apps/admin/src/automations/components/automations-list.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import AutomationsList from './automations-list';
import React from 'react';
import {MemoryRouter} from 'react-router';
import {MemoryRouter, Route, Routes, useParams} from 'react-router';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {render, screen} from '@testing-library/react';
import {fireEvent, render, screen} from '@testing-library/react';

const automations = [{
id: 'automation-id-1',
Expand All @@ -28,6 +28,21 @@ const automations = [{

const renderWithRouter = (ui: React.ReactElement) => render(<MemoryRouter>{ui}</MemoryRouter>);

const AutomationEditorRoute = () => {
const {id} = useParams();

return <div>Automation editor: {id}</div>;
};

const renderWithRoutes = () => render(
<MemoryRouter initialEntries={['/automations']}>
<Routes>
<Route element={<AutomationsList automations={automations} showRunAnalytics={true} />} path="/automations" />
<Route element={<AutomationEditorRoute />} path="/automations/:id" />
</Routes>
</MemoryRouter>
);

describe('AutomationsList', () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down Expand Up @@ -80,6 +95,33 @@ describe('AutomationsList', () => {
expect(screen.getByRole('link', {name: 'Paid member welcome flow'})).toHaveAttribute('href', '/automations/automation-id-2');
});

it('follows the row link when clicking another cell', () => {
renderWithRoutes();

fireEvent.click(screen.getByText('1,432'));

expect(screen.getByText('Automation editor: automation-id-1')).toBeInTheDocument();
});

it('lets the existing row link handle its own navigation', () => {
renderWithRoutes();

fireEvent.click(screen.getByRole('link', {name: 'Free member welcome flow'}));

expect(screen.getByText('Automation editor: automation-id-1')).toBeInTheDocument();
});

it('does not follow the row link when the click is default-prevented', () => {
renderWithRoutes();
const analyticsCell = screen.getByText('1,432');
analyticsCell.addEventListener('click', event => event.preventDefault(), {once: true});

fireEvent.click(analyticsCell);

expect(screen.queryByText('Automation editor: automation-id-1')).not.toBeInTheDocument();
expect(analyticsCell).toBeInTheDocument();
});

it('renders a table skeleton while loading', () => {
renderWithRouter(<AutomationsList isLoading={true} showRunAnalytics={true} />);

Expand Down
15 changes: 12 additions & 3 deletions apps/admin/src/automations/components/automations-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ const AUTOMATION_STAT_COLUMNS = [
{key: 'inProgressEntries', label: 'In progress', widthClassName: 'w-32', skeletonWidthClassName: 'w-10'}
] as const;

const handleRowClick = (event: React.MouseEvent<HTMLTableRowElement>) => {
if (event.defaultPrevented || !(event.target instanceof Element) || event.target.closest('a, button, input, select, textarea')) {
return;
}

event.currentTarget.querySelector('a')?.click();
};

interface AutomationsListProps {
automations?: AutomationBrowseItem[];
isLoading?: boolean;
Expand Down Expand Up @@ -94,12 +102,13 @@ const AutomationsList: React.FC<AutomationsListProps> = ({automations = [], isLo
return (
<TableRow
key={automation.slug}
className="grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 p-2 hover:bg-table-row-hover lg:table-row lg:p-0"
className="grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 p-2 hover:bg-table-row-hover has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-[-2px] has-[:focus-visible]:outline-focus-ring lg:table-row lg:p-0"
data-testid="automation-list-row"
onClick={handleRowClick}
>
<TableHead className="static h-auto min-w-0 p-0 text-left text-base font-normal tracking-normal text-foreground lg:table-cell lg:p-4" scope="row">
<TableHead className="h-auto min-w-0 p-0 text-left text-base font-normal tracking-normal text-foreground lg:table-cell lg:p-4" scope="row">
<Link
className="before:absolute before:inset-0 before:z-10 before:rounded-sm focus-visible:outline-hidden focus-visible:before:ring-2 focus-visible:before:ring-focus-ring"
className="rounded-sm focus-visible:outline-hidden"
to={`/automations/${automation.id}`}
>
<span className="block text-md font-semibold">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ const EmailContentModal: React.FC<EmailContentModalProps> = ({
ref={setDialogContentNode}
aria-describedby={undefined}
className='top-0 left-0 h-[100dvh] w-full max-w-full translate-0 grid-rows-[1fr] gap-0 rounded-none border-0 p-0 shadow-none outline-hidden sm:rounded-none dark:bg-[#151719]'
data-sentry-mask='true'
onEscapeKeyDown={(event) => {
if (isKoenigPortalFocused()) {
// prevent Radix dismissing the dialog but let the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ const EmailPreviewFrame: React.FC<EmailPreviewFrameProps> = ({previewState}) =>
<iframe
ref={previewIframeRef}
className='w-full rounded bg-white'
data-sentry-block='true'
data-testid='email-preview-iframe'
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
srcDoc={previewState.html}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const TestEmailDropdown: React.FC<TestEmailDropdownProps> = ({
};

return (
<PopoverContent align='end' className='w-[260px] p-4' data-testid='test-email-dropdown' sideOffset={8}>
<PopoverContent align='end' className='w-[260px] p-4' data-sentry-mask='true' data-testid='test-email-dropdown' sideOffset={8}>
<div className='mb-3'>
<label className='mb-2 block text-sm font-semibold' htmlFor='test-email-input'>Send test email</label>
<Input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const preparePreviewHtml = (html: string) => {
const parser = new DOMParser();
const parsed = parser.parseFromString(html, 'text/html');

parsed.body.setAttribute('data-sentry-mask', 'true');

parsed.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((link) => {
link.target = '_blank';
link.rel = 'noopener noreferrer';
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/automations/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ const AutomationEditorContent: React.FC<{automationId: string}> = ({automationId
}, []);

return (
<div className='fixed inset-0 z-50 flex flex-col bg-background' data-testid='automation-editor'>
<div className='fixed inset-0 z-50 flex flex-col bg-background' data-sentry-mask='true' data-testid='automation-editor'>
<AutomationHeader
automation={draft}
isLoadingAutomation={isEditorLoading}
Expand Down
33 changes: 0 additions & 33 deletions apps/admin/src/posts/analytics/components/post-analytics-view.tsx

This file was deleted.

80 changes: 80 additions & 0 deletions apps/ember-admin/app/routes/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,76 @@ function K() {
return this;
}

const AUTOMATIONS_REPLAY_SAMPLE_RATE = 1;
const AUTOMATIONS_REPLAY_MASK_ATTRIBUTE = 'data-sentry-automations-mask';

function isAutomationsUrl(url) {
const path = new URL(url).hash.replace(/^#/, '').split('?')[0].replace(/\/+$/, '');
return path === '/automations' || path.startsWith('/automations/');
}

function setupAutomationsSessionReplay(replay, shouldStartRecording) {
let initialRouteCheck;
let removeNavigationListener;
let recordingStarted = false;

const teardown = () => {
clearTimeout(initialRouteCheck);
removeNavigationListener?.();
document.body.removeAttribute(AUTOMATIONS_REPLAY_MASK_ATTRIBUTE);
};

const updateAutomationsMask = (url) => {
const isAutomations = isAutomationsUrl(url);

if (isAutomations) {
document.body.setAttribute(AUTOMATIONS_REPLAY_MASK_ATTRIBUTE, 'true');
} else {
document.body.removeAttribute(AUTOMATIONS_REPLAY_MASK_ATTRIBUTE);
}

return isAutomations;
};

const maybeStartRecording = (url) => {
const isAutomations = updateAutomationsMask(url);

if (!shouldStartRecording || !isAutomations || recordingStarted) {
return;
}

recordingStarted = true;
clearTimeout(initialRouteCheck);

replay.stop().then(() => replay.start()).catch((error) => {
try {
replay.startBuffering();
} catch (e) {
// Replay is still running, nothing to restore
}
console.error('Error starting Sentry Replay recording:', error); // eslint-disable-line no-console
});
};

// Keep listening after recording starts so portalled Automations content is
// masked only while an Automations route is active. React-owned admin
// routes navigate via pushState, which doesn't fire `hashchange`.
if (window.navigation) {
const onNavigate = event => maybeStartRecording(event.destination.url);
window.navigation.addEventListener('navigate', onNavigate);
removeNavigationListener = () => window.navigation.removeEventListener('navigate', onNavigate);
}

// Mask direct Automations loads before Replay creates its initial buffer.
updateAutomationsMask(window.location.href);

// Replay defers its sampling initialization during Sentry.init(). Queue the
// initial route check behind it to avoid starting a second rrweb recorder.
initialRouteCheck = setTimeout(() => maybeStartRecording(window.location.href));

return teardown;
}

let shortcuts = {};

shortcuts.esc = {action: 'closeMenus', scope: 'default'};
Expand Down Expand Up @@ -192,6 +262,7 @@ export default Route.extend(ShortcutsRoute, {
},

willDestroy() {
this._cleanupAutomationsSessionReplay?.();
this.ui.cleanupBodyDragHandlers();
},

Expand All @@ -203,6 +274,15 @@ export default Route.extend(ShortcutsRoute, {
if (this.config.sentry_dsn) {
const sentryConfig = getSentryConfig(this.config.sentry_dsn, this.config.sentry_env, this.config.version);
Sentry.init(sentryConfig);

// Keep error-triggered replay buffering everywhere and mask all
// Automations portals. Once a sampled app load enters Automations,
// record a full session replay for the rest of that load.
const replay = Sentry.getClient()?.getIntegrationByName('Replay');
if (replay) {
const shouldStartRecording = Math.random() < AUTOMATIONS_REPLAY_SAMPLE_RATE;
this._cleanupAutomationsSessionReplay = setupAutomationsSessionReplay(replay, shouldStartRecording);
}
}

if (this.session.isAuthenticated) {
Expand Down
8 changes: 6 additions & 2 deletions apps/ember-admin/app/utils/sentry.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ export function getSentryConfig(dsn, environment, appVersion, transport) {
// Replace with `Sentry.replayIntegration()` once we've migrated to @sentry/ember 8.x
// Docs: https://docs.sentry.io/platforms/javascript/migration/v7-to-v8/#removal-of-sentryreplay-package
new Replay({
mask: ['.koenig-lexical', '.gh-dashboard'],
unmask: ['[role="menu"]', '[data-testid="settings-panel"]', '.gh-nav'],
mask: ['.koenig-lexical', '.gh-dashboard', '[data-sentry-automations-mask]'],
unmask: [
'body:not([data-sentry-automations-mask]) [role="menu"]',
'body:not([data-sentry-automations-mask]) [data-testid="settings-panel"]',
'body:not([data-sentry-automations-mask]) .gh-nav'
],
maskAllText: false,
maskAllInputs: true,
blockAllMedia: true
Expand Down
14 changes: 8 additions & 6 deletions ghost/core/core/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ async function initServices({ghostServer, config, prometheusClient}) {
const statsService = require('./server/services/stats');
const explorePingService = require('./server/services/explore-ping');
const domainEvents = require('@tryghost/domain-events');
const automations = require('./server/services/automations');
const {automationsService} = require('./server/services/automations');
const automationsApi = require('./server/services/automations/automations-api');
const adapterManager = require('./server/services/adapter-manager').default;
const {withErrorCapture} = require('./server/adapters/scheduling/error-capture');
Expand Down Expand Up @@ -424,7 +424,7 @@ async function initServices({ghostServer, config, prometheusClient}) {
internalKeys
}),
machinePaymentsService.init(),
automations.init({
automationsService.init({
domainEvents,
apiUrl,
schedulerAdapter,
Expand Down Expand Up @@ -531,6 +531,7 @@ async function bootGhost({backend = true, frontend = true, server = true} = {})
// We need access to these variables in both the try and catch block
let bootLogger;
let config;
let flushLogs;
let ghostServer;
let logging;
let metrics;
Expand All @@ -553,6 +554,7 @@ async function bootGhost({backend = true, frontend = true, server = true} = {})
debug('Begin: Load logging');
logging = require('@tryghost/logging');
metrics = require('@tryghost/metrics');
flushLogs = require('./shared/flush-logs').flushLogs;
bootLogger = new BootLogger(logging, metrics, startTime);
debug('End: Load logging');

Expand Down Expand Up @@ -659,6 +661,7 @@ async function bootGhost({backend = true, frontend = true, server = true} = {})

// If we pass the env var, kill Ghost
if (process.env.GHOST_CI_SHUTDOWN_AFTER_BOOT) {
await flushLogs();
process.exit(0);
}

Expand Down Expand Up @@ -688,10 +691,9 @@ async function bootGhost({backend = true, frontend = true, server = true} = {})
notifyServerReady(serverStartError);
ghostServer.shutdown(2);
} else {
// Ghost server failed to start, set a timeout to give logging a chance to flush
setTimeout(() => {
process.exit(2);
}, 100);
// Ghost server failed to start, drain the log transports before exiting
await flushLogs();
process.exit(2);
}
}
}
Expand Down
Loading
Loading