Show output of preflight script (#6400)

This commit is contained in:
Kamil Kisiela 2025-01-21 17:50:20 +01:00 committed by GitHub
parent fc89aeda4d
commit d2a4387b64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 261 additions and 7 deletions

View file

@ -0,0 +1,5 @@
---
'hive': minor
---
Display logs from the Preflight Script in Laboratory

View file

@ -303,4 +303,94 @@ describe('Execution', () => {
cy.get('.graphiql-execute-button').click();
cy.wait('@post');
});
it('logs are visible when opened', () => {
cy.dataCy('toggle-preflight-script').click();
cy.dataCy('preflight-script-modal-button').click();
setMonacoEditorContents(
'preflight-script-editor',
dedent`
console.info(1)
console.warn(true)
console.error('Fatal')
throw new TypeError('Test')
`,
);
cy.dataCy('preflight-script-modal-submit').click();
cy.intercept({
method: 'POST',
}).as('post');
// shows no logs before executing
cy.get('#preflight-script-logs button[data-cy="trigger"]').click({
// it's because the button is not fully visible on the screen
force: true,
});
cy.get('#preflight-script-logs [data-cy="logs"]').should(
'contain',
['No logs available', 'Execute a query to see logs'].join(''),
);
cy.get('.graphiql-execute-button').click();
cy.wait('@post');
cy.get('#preflight-script-logs [data-cy="logs"]').should(
'contain',
[
'> start running script: > Start running script',
'info: 1 (Line: 1, Column: 1)',
'warn: true (Line: 2, Column: 1)',
'error: Fatal (Line: 3, Column: 1)',
'error: TypeError: Test (Line: 4, Column: 7)',
'> preflight script failed: > Preflight script failed',
].join(''),
);
});
it('logs are cleared when requested', () => {
cy.dataCy('toggle-preflight-script').click();
cy.dataCy('preflight-script-modal-button').click();
setMonacoEditorContents(
'preflight-script-editor',
dedent`
console.info(1)
console.warn(true)
console.error('Fatal')
throw new TypeError('Test')
`,
);
cy.dataCy('preflight-script-modal-submit').click();
cy.intercept({
method: 'POST',
}).as('post');
cy.get('.graphiql-execute-button').click();
cy.wait('@post');
// open logs
cy.get('#preflight-script-logs button[data-cy="trigger"]').click({
// it's because the button is not fully visible on the screen
force: true,
});
cy.get('#preflight-script-logs [data-cy="logs"]').should(
'contain',
[
'> start running script: > Start running script',
'info: 1 (Line: 1, Column: 1)',
'warn: true (Line: 2, Column: 1)',
'error: Fatal (Line: 3, Column: 1)',
'error: TypeError: Test (Line: 4, Column: 7)',
'> preflight script failed: > Preflight script failed',
].join(''),
);
cy.get('#preflight-script-logs button[data-cy="erase-logs"]').click();
cy.get('#preflight-script-logs [data-cy="logs"]').should(
'contain',
['No logs available', 'Execute a query to see logs'].join(''),
);
});
});

View file

@ -30,6 +30,7 @@
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-hover-card": "1.1.4",

View file

@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -140,7 +140,7 @@ const PreflightScript_TargetFragment = graphql(`
}
`);
type LogRecord = LogMessage | { type: 'separator' };
export type LogRecord = LogMessage | { type: 'separator' };
function safeParseJSON(str: string): Record<string, unknown> | null {
try {
@ -150,7 +150,7 @@ function safeParseJSON(str: string): Record<string, unknown> | null {
}
}
const enum PreflightWorkerState {
export const enum PreflightWorkerState {
running,
ready,
}

View file

@ -1,14 +1,16 @@
import { ReactElement, useCallback, useMemo, useState } from 'react';
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cx } from 'class-variance-authority';
import clsx from 'clsx';
import { GraphiQL } from 'graphiql';
import { buildSchema } from 'graphql';
import { ChevronDownIcon, EraserIcon } from 'lucide-react';
import { Helmet } from 'react-helmet-async';
import { useMutation, useQuery } from 'urql';
import { Page, TargetLayout } from '@/components/layouts/target';
import { ConnectLabModal } from '@/components/target/laboratory/connect-lab-modal';
import { CreateOperationModal } from '@/components/target/laboratory/create-operation-modal';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { DocsLink } from '@/components/ui/docs-note';
import {
DropdownMenu,
@ -34,6 +36,7 @@ import { useSyncOperationState } from '@/lib/hooks/laboratory/use-sync-operation
import { useOperationFromQueryString } from '@/lib/hooks/laboratory/useOperationFromQueryString';
import { useResetState } from '@/lib/hooks/use-reset-state';
import {
LogRecord,
preflightScriptPlugin,
PreflightScriptProvider,
usePreflightScript,
@ -54,6 +57,7 @@ import 'graphiql/style.css';
import '@graphiql/plugin-explorer/style.css';
import { PromptManager, PromptProvider } from '@/components/ui/prompt';
import { useRedirect } from '@/lib/access/common';
import { captureException } from '@sentry/react';
const explorer = explorerPlugin();
@ -518,7 +522,7 @@ function LaboratoryPageContent(props: {
.graphiql-dialog a {
--color-primary: 40, 89%, 60% !important;
}
.graphiql-container {
overflow: unset; /* remove default overflow */
}
@ -529,19 +533,19 @@ function LaboratoryPageContent(props: {
line-height: 1.75rem !important;
color: white;
}
.graphiql-container,
.graphiql-dialog,
.CodeMirror-info {
--color-base: 223, 70%, 3.9% !important;
}
.graphiql-tooltip,
.graphiql-dropdown-content,
.CodeMirror-lint-tooltip {
background: #030711;
}
.graphiql-tab {
white-space: nowrap;
}
@ -549,6 +553,30 @@ function LaboratoryPageContent(props: {
.graphiql-sidebar > button.active {
background-color: hsla(var(--color-neutral),var(--alpha-background-light))
}
.graphiql-container .graphiql-footer {
border: 0;
margin-top: 15px;
}
#preflight-script-logs {
background-color: hsl(var(--color-base));
border-radius: var(--border-radius-12);
box-shadow: var(--popover-box-shadow);
color: hsla(var(--color-neutral), var(--alpha-tertiary));
}
#preflight-script-logs h2 {
color: hsla(var(--color-neutral), var(--alpha-secondary));
}
#preflight-script-logs button[data-state="open"] > h2 {
color: hsl(var(--color-neutral));
}
#preflight-script-logs > div {
border-color: hsl(var(--border));
}
`}</style>
</Helmet>
{!query.fetching && !query.stale && (
@ -592,6 +620,16 @@ function LaboratoryPageContent(props: {
</>
)}
</GraphiQL.Toolbar>
<GraphiQL.Footer>
<div>
{preflightScript.isPreflightScriptEnabled ? (
<PreflightScriptLogs
logs={preflightScript.logs}
onClear={preflightScript.clearLogs}
/>
) : null}
</div>
</GraphiQL.Footer>
</GraphiQL>
</PreflightScriptProvider>
)}
@ -655,3 +693,111 @@ function useApiTabValueState(graphqlEndpointUrl: string | null) {
),
] as const;
}
function PreflightScriptLogs(props: { logs: LogRecord[]; onClear: () => void }) {
const [isOpen, setIsOpen] = useState(false);
const consoleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const consoleEl = consoleRef.current;
consoleEl?.scroll({ top: consoleEl.scrollHeight, behavior: 'smooth' });
}, [props.logs, isOpen]);
const logColor = {
error: 'text-red-400',
info: 'text-emerald-400',
warn: 'text-yellow-400',
log: '', // default
};
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className={cn('flex max-h-[200px] w-full flex-col overflow-hidden bg-[#030711]')}
id="preflight-script-logs"
>
<div
className={cn(
'flex shrink-0 items-center justify-between px-4 py-3',
isOpen ? 'border-b' : 'border-b-0',
)}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex h-auto items-center gap-2 p-0 hover:bg-transparent"
data-cy="trigger"
>
<ChevronDownIcon
className={`size-4 text-gray-500 transition-transform ${
isOpen ? 'rotate-0' : '-rotate-90'
}`}
/>
<h2 className="text-[15px] font-normal">Preflight Script Logs</h2>
</Button>
</CollapsibleTrigger>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
data-cy="erase-logs"
className={cn(
'size-8 text-gray-500 hover:text-white',
isOpen ? 'visible' : 'invisible',
)}
onClick={props.onClear}
>
<EraserIcon className="size-4" />
<span className="sr-only">Clear logs</span>
</Button>
</div>
</div>
<CollapsibleContent
className="grow overflow-auto p-4 font-mono text-xs/[18px]"
ref={consoleRef}
data-cy="logs"
>
{props.logs.length === 0 ? (
<div
data-cy="empty-state"
className="flex flex-col items-center justify-center text-gray-400"
>
<p>No logs available</p>
<p>Execute a query to see logs</p>
</div>
) : (
<>
{props.logs.map((log, index) => {
if (typeof log !== 'string' && 'type' in log && log.type === 'separator') {
return <hr key={index} className="my-2 border-dashed border-current" />;
}
let logType: 'error' | 'warn' | 'info' | 'log' = 'log';
let logMessage = '';
if (log instanceof Error) {
logType = 'error';
logMessage = `${log.name}: ${log.message}`;
} else if (typeof log === 'string') {
logType = log.split(':')[0].toLowerCase() as 'error' | 'warn' | 'info' | 'log';
logMessage = log.substring(log.indexOf(':') + 1).trim();
} else {
captureException(new Error('Unexpected log type in Preflight Script Logs'), {
extra: { log },
});
return null;
}
return (
<div key={index} className={logColor[logType] ?? ''}>
{logType}: {logMessage}
</div>
);
})}
</>
)}
</CollapsibleContent>
</Collapsible>
);
}

View file

@ -1718,6 +1718,9 @@ importers:
'@radix-ui/react-checkbox':
specifier: 1.1.3
version: 1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-collapsible':
specifier: 1.1.2
version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dialog':
specifier: 1.1.4
version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)