Provide additional logsobservability to workflow runs (per node) (#21142)
Surfaces per-step "Logs" tabs in the workflow run side panel so users can see what each step actually did (model + tokens + tool calls for AI, console output for serverless functions, request/response for HTTP, recipients/body for Email). <img width="546" height="501" alt="ai_agent_without_websearch" src="https://github.com/user-attachments/assets/c6ca3518-9489-4484-a570-3d0569ff3b03" /> ## Storage - New `stepLogs` JSONB column on the `workflowRun` workspace entity, typed as `Record<string, WorkflowRunStepLog>` (keyed by step id). - Schema lives in `twenty-shared`: `workflowRunStepLogSchema` with a discriminated `details.type` union for `AI_AGENT | CODE | HTTP_REQUEST | EMAIL` — frontends and backends consume the same Zod-inferred type. - Field is added to existing workspaces via a workspace upgrade command (`2-9 add-workflow-run-step-logs-field`); the standard-object metadata declares it for new workspaces. - Writes happen atomically per step in `WorkflowRunStepLogWorkspaceService.setStepLog` using `jsonb_set`. That lets concurrent steps in the same run write their own keys without contending with the existing lock around `workflowRun.state`. - Per-step payload is hard-capped at 256 KB; anything larger is dropped with a `logger.warn`, so a pathological tool call can never bloat a row. See below for more information. ## How logs are produced **Aalmost everything was already being collected; this PR mostly persists and renders it.** - **AI agent** — `AgentAsyncExecutorService` already tracked token usage, model id, native web-search count, and the AI SDK's `steps[]`. We map those into the log via `mapAiStepsToToolCallLogs` (`searchVector` stripped from record outputs, per-call input/output capped at 32/64 KB, max 200 tool calls per step). The only new measurement is a wall-clock `durationMs` taken around `executeAgent`, and we now fold native web-search cost into the displayed `totalCostInDollars` (it was already billed, just not shown). - **Code / serverless function** — reuses the `console.log` output the function runner already returns (`logsByLevel`); `build-code-step-log.util` only repackages it. - **HTTP request** — built from the action's existing input/output via `build-http-request-step-log.util`. No new signals collected. - **Email (send / draft)** — added `sanitizedHtmlBody` + `plainTextBody` to the existing tool outputs (a small additive change), then `build-email-step-log.util` consumes them. No additional AI inference or external calls are made for logging — the cost is a small CPU overhead per step plus the JSONB write. ## Security The log surface intentionally shows whatever the workflow touched, which made redaction and sanitization the main design concern. - **HTTP — secrets in headers**: existing `SENSITIVE_HEADER_NAMES` set (Authorization, Cookie, …) replaced with `[redacted]` in both request and response. - **HTTP — secrets in URLs**: `SENSITIVE_URL_PARAM_NAMES` (e.g. `api_key`, `token`, `access_token`) replaced in the query string via `URL`-based parsing. - **HTTP — secrets in bodies**: `SENSITIVE_BODY_KEY_REGEX` deep-walks JSON request/response bodies (object input or stringified JSON) and redacts matching keys. Applied to the `error` field too, since transport-layer errors sometimes embed structured payloads. - **Email — XSS risk in body preview**: tool outputs now expose a server-side `sanitizedHtmlBody`; the log builder prefers it over the raw user-authored `input.body`, with `plainTextBody` as a second fallback. The original raw body is only used if sanitization didn't happen (e.g. tool failed before composing). - **AI — internal/noisy data**: `searchVector` (Postgres tsvector strings) is stripped from record outputs returned by Twenty tools to avoid leaking internal full-text-search payloads. - **DB bloat / runaway agents**: 256 KB per-step cap + 32 KB / 64 KB per-tool-call input/output cap + 200 tool calls per step. <img width="547" height="307" alt="logic_function" src="https://github.com/user-attachments/assets/dd4a3d16-67f2-434b-95b3-bdcaf9ed053d" /> ## More details on Log size & truncation Logs are stored in `workflowRun.stepLogs` (JSONB), keyed by `stepId`. ### Per-step cap Each step's log is hard-capped at **256 KB** (`MAX_STEP_LOG_BYTES` in `WorkflowRunStepLogWorkspaceService.setStepLog`). For ~99% of workflows this is roomy — typical real-world sizes: - Code / serverless function: 1–20 KB - HTTP request: 5–70 KB - Email: 5–30 KB - AI agent (a handful of tool calls): 5–50 KB ### Two layers of bounding 1. **Per-field truncation** in each builder (before writing): - **Code**: ≤ 500 entries, ≤ 4 KB per message, ≤ 8 KB stack trace - **HTTP**: ≤ 32 KB per body (request + response), UTF-8 byte-aware - **Email**: ≤ 8 KB body preview, UTF-8 byte-aware - **AI agent**: ≤ 32 KB tool input, ≤ 64 KB tool output, ≤ 200 tool calls/step 2. **Global per-step safety net** at write time: if the assembled `stepLog` still exceeds 256 KB, the write is **dropped entirely** with a `logger.warn`. The workflow itself keeps running unaffected. ### What this means in practice - **Safe**: workflow execution, step results, downstream steps — never blocked by log size. - **Safe**: iterators (each iteration overwrites the previous log for that `stepId`, so they can't accumulate). - **Safe**: step retries (same `stepId` is overwritten, not appended). - **Possible**: an AI agent step with many large tool outputs (e.g., 50+ heavy `web_search` calls) can exceed 256 KB → the **entire** step's log is dropped, side panel shows "No logs were recorded for this step". The user has no explicit signal that the log was dropped due to size (only server-side warn). - **Possible** (theoretical): a workflow with hundreds of distinct steps could push the row toward Postgres's internal ~256 MB jsonb limit. Beyond that, individual `jsonb_set` writes would error and be swallowed by the action's try/catch — workflow still completes. ### Possible future hardening (not in this PR) - Replace "drop entire log" with a stub that preserves the summary card (cost, duration, status) and marks `truncated.reason = 'size_cap'`. - Surface size-drops in the UI (similar to the existing `<StyledTruncatedNotice>`). - Emit a metric so dropped logs are observable in dashboards.
This commit is contained in:
+220
@@ -0,0 +1,220 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Fragment } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type AiAgentStepLogDetails } from 'twenty-shared/workflow';
|
||||
import {
|
||||
IconBrain,
|
||||
IconClock,
|
||||
IconCoins,
|
||||
IconCpu,
|
||||
IconTool,
|
||||
IconWorld,
|
||||
} from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { formatDuration } from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters';
|
||||
import {
|
||||
StyledBadgeGroup,
|
||||
StyledEmptyHint,
|
||||
StyledHeaderLeft,
|
||||
StyledMetric,
|
||||
StyledMetricLabel,
|
||||
StyledMetricsRow,
|
||||
StyledMetricValue,
|
||||
StyledSection,
|
||||
StyledSectionTitle,
|
||||
StyledSummaryCard,
|
||||
StyledSummaryHeader,
|
||||
StyledTitle,
|
||||
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles';
|
||||
import { WorkflowRunStepLogsToolCallRow } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsToolCallRow';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
const StyledModelBadge = styled.span`
|
||||
background: ${themeCssVariables.background.transparent.light};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-family: ${themeCssVariables.font.family};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledUsageGrid = styled.div`
|
||||
background: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: grid;
|
||||
gap: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[4]};
|
||||
grid-template-columns: max-content 1fr;
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledUsageLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledUsageValue = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-family: ${themeCssVariables.font.family};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
const StyledToolList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const formatCost = (dollars: number): string => {
|
||||
if (dollars === 0) {
|
||||
return '$0';
|
||||
}
|
||||
|
||||
if (Math.abs(dollars) < 0.01) {
|
||||
return `$${dollars.toFixed(4)}`;
|
||||
}
|
||||
|
||||
return `$${formatNumber(dollars, { decimals: 2 })}`;
|
||||
};
|
||||
|
||||
const formatTokenCount = (tokens: number): string =>
|
||||
formatNumber(tokens, { abbreviate: true, decimals: 1 });
|
||||
|
||||
export const WorkflowRunStepLogsAiAgentDetail = ({
|
||||
details,
|
||||
}: {
|
||||
details: AiAgentStepLogDetails;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const {
|
||||
usage,
|
||||
cost,
|
||||
modelId,
|
||||
toolCalls,
|
||||
nativeWebSearchCallCount,
|
||||
durationMs,
|
||||
} = details;
|
||||
|
||||
const usageRows = [
|
||||
{ label: t`Input`, value: usage.inputTokens, show: true },
|
||||
{ label: t`Output`, value: usage.outputTokens, show: true },
|
||||
{
|
||||
label: t`Reasoning`,
|
||||
value: usage.reasoningTokens,
|
||||
show: isDefined(usage.reasoningTokens) && usage.reasoningTokens > 0,
|
||||
},
|
||||
{
|
||||
label: t`Cached read`,
|
||||
value: usage.cacheReadTokens,
|
||||
show: isDefined(usage.cacheReadTokens) && usage.cacheReadTokens > 0,
|
||||
},
|
||||
{
|
||||
label: t`Cached creation`,
|
||||
value: usage.cacheCreationTokens,
|
||||
show:
|
||||
isDefined(usage.cacheCreationTokens) && usage.cacheCreationTokens > 0,
|
||||
},
|
||||
].filter((row) => row.show);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledSummaryCard>
|
||||
<StyledSummaryHeader>
|
||||
<StyledHeaderLeft>
|
||||
<IconBrain size={16} />
|
||||
<StyledTitle>{t`AI agent run`}</StyledTitle>
|
||||
</StyledHeaderLeft>
|
||||
<StyledBadgeGroup>
|
||||
<StyledModelBadge>{modelId}</StyledModelBadge>
|
||||
</StyledBadgeGroup>
|
||||
</StyledSummaryHeader>
|
||||
|
||||
<StyledMetricsRow>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconCpu size={12} />
|
||||
{t`Tokens`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>
|
||||
{formatTokenCount(usage.totalTokens)}
|
||||
</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconCoins size={12} />
|
||||
{t`Cost`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>
|
||||
{formatCost(cost.totalCostInDollars)}
|
||||
</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconTool size={12} />
|
||||
{t`Tool calls`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{toolCalls.length}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
{nativeWebSearchCallCount > 0 && (
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconWorld size={12} />
|
||||
{t`Web searches`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{nativeWebSearchCallCount}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
)}
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconClock size={12} />
|
||||
{t`Duration`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{formatDuration(durationMs)}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
</StyledMetricsRow>
|
||||
</StyledSummaryCard>
|
||||
|
||||
{usageRows.length > 0 && (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Token usage`}</StyledSectionTitle>
|
||||
<StyledUsageGrid>
|
||||
{usageRows.map((row) => (
|
||||
<Fragment key={row.label}>
|
||||
<StyledUsageLabel>{row.label}</StyledUsageLabel>
|
||||
<StyledUsageValue>
|
||||
{formatNumber(row.value ?? 0)}
|
||||
</StyledUsageValue>
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledUsageGrid>
|
||||
</StyledSection>
|
||||
)}
|
||||
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>
|
||||
{toolCalls.length > 0
|
||||
? t`Tool calls (${toolCalls.length})`
|
||||
: t`Tool calls`}
|
||||
</StyledSectionTitle>
|
||||
{toolCalls.length === 0 ? (
|
||||
<StyledEmptyHint>{t`No tools were called`}</StyledEmptyHint>
|
||||
) : (
|
||||
<StyledToolList>
|
||||
{toolCalls.map((toolCall) => (
|
||||
<WorkflowRunStepLogsToolCallRow
|
||||
key={toolCall.toolCallId}
|
||||
toolCall={toolCall}
|
||||
/>
|
||||
))}
|
||||
</StyledToolList>
|
||||
)}
|
||||
</StyledSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconTerminal,
|
||||
} from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily';
|
||||
import { formatDuration } from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters';
|
||||
import {
|
||||
StyledErrorCard,
|
||||
StyledErrorMessageText,
|
||||
StyledHeaderLeft,
|
||||
StyledMetric,
|
||||
StyledMetricLabel,
|
||||
StyledMetricsRow,
|
||||
StyledMetricValue,
|
||||
StyledSection,
|
||||
StyledSectionTitle,
|
||||
StyledStatusBadge,
|
||||
StyledSummaryCard,
|
||||
StyledSummaryHeader,
|
||||
StyledTitle,
|
||||
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles';
|
||||
|
||||
const StyledErrorHeader = styled.div`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.color.red};
|
||||
display: flex;
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledStackTrace = styled.pre`
|
||||
background: ${themeCssVariables.background.tertiary};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-family: ${MONOSPACE_FONT_FAMILY};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
margin: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding: ${themeCssVariables.spacing[2]};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
type CodeDetails = Extract<WorkflowRunStepLog['details'], { type: 'CODE' }>;
|
||||
|
||||
export const WorkflowRunStepLogsCodeDetail = ({
|
||||
details,
|
||||
}: {
|
||||
details: CodeDetails;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const isSuccess = details.status === 'SUCCESS';
|
||||
const StatusIcon = isSuccess ? IconCheck : IconAlertTriangle;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledSummaryCard>
|
||||
<StyledSummaryHeader>
|
||||
<StyledHeaderLeft>
|
||||
<IconTerminal size={16} />
|
||||
<StyledTitle>{t`Function run`}</StyledTitle>
|
||||
</StyledHeaderLeft>
|
||||
<StyledStatusBadge isSuccess={isSuccess}>
|
||||
<StatusIcon size={12} />
|
||||
{isSuccess ? t`Success` : t`Error`}
|
||||
</StyledStatusBadge>
|
||||
</StyledSummaryHeader>
|
||||
<StyledMetricsRow>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconClock size={12} />
|
||||
{t`Duration`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>
|
||||
{formatDuration(details.durationMs)}
|
||||
</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
</StyledMetricsRow>
|
||||
</StyledSummaryCard>
|
||||
|
||||
{isDefined(details.error) && details.error !== null && (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Error`}</StyledSectionTitle>
|
||||
<StyledErrorCard>
|
||||
<StyledErrorHeader>
|
||||
<IconAlertTriangle size={14} />
|
||||
{details.error.type}
|
||||
</StyledErrorHeader>
|
||||
<StyledErrorMessageText>
|
||||
{details.error.message}
|
||||
</StyledErrorMessageText>
|
||||
{isDefined(details.error.stackTrace) &&
|
||||
details.error.stackTrace.length > 0 && (
|
||||
<StyledStackTrace>{details.error.stackTrace}</StyledStackTrace>
|
||||
)}
|
||||
</StyledErrorCard>
|
||||
</StyledSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { workflowRunStepLogSchema } from 'twenty-shared/workflow';
|
||||
import { IconInfoCircle } from 'twenty-ui/display';
|
||||
import { isTwoFirstDepths, JsonTree } from 'twenty-ui/json-visualizer';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type JsonValue } from 'type-fest';
|
||||
|
||||
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
|
||||
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
|
||||
import { useWorkflowRunStepLog } from '@/workflow/hooks/useWorkflowRunStepLog';
|
||||
import { WorkflowRunStepLogsAiAgentDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsAiAgentDetail';
|
||||
import { WorkflowRunStepLogsCodeDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsCodeDetail';
|
||||
import { WorkflowRunStepLogsEmailDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsEmailDetail';
|
||||
import { WorkflowRunStepLogsEntries } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsEntries';
|
||||
import { WorkflowRunStepLogsHttpRequestDetail } from '@/workflow/workflow-run/observability/WorkflowRunStepLogsHttpRequestDetail';
|
||||
import { getIsDescendantOfIterator } from '@/workflow/workflow-steps/utils/getIsDescendantOfIterator';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
const StyledRoot = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
overflow: hidden scroll;
|
||||
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledEmptyState = styled.div`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
padding: ${themeCssVariables.spacing[8]} ${themeCssVariables.spacing[3]};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledTruncatedNotice = styled.div`
|
||||
background: ${themeCssVariables.background.transparent.orange};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
export const WorkflowRunStepLogsDetail = ({ stepId }: { stepId: string }) => {
|
||||
const { t } = useLingui();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
const workflowRunId = useWorkflowRunIdOrThrow();
|
||||
const flow = useFlowOrThrow();
|
||||
|
||||
const rawStepLog = useWorkflowRunStepLog({ workflowRunId, stepId });
|
||||
|
||||
if (!isDefined(rawStepLog)) {
|
||||
return (
|
||||
<StyledRoot>
|
||||
<StyledEmptyState>
|
||||
<IconInfoCircle size={20} />
|
||||
<div>{t`No logs were recorded for this step.`}</div>
|
||||
</StyledEmptyState>
|
||||
</StyledRoot>
|
||||
);
|
||||
}
|
||||
|
||||
const parseResult = workflowRunStepLogSchema.safeParse(rawStepLog);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return (
|
||||
<StyledRoot>
|
||||
<JsonTree
|
||||
value={rawStepLog as JsonValue}
|
||||
shouldExpandNodeInitially={isTwoFirstDepths}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
</StyledRoot>
|
||||
);
|
||||
}
|
||||
|
||||
const stepLog = parseResult.data;
|
||||
|
||||
const isInsideIteratorLoop = isDefined(flow.steps)
|
||||
? getIsDescendantOfIterator({ stepId, steps: flow.steps })
|
||||
: false;
|
||||
|
||||
const renderDetails = () => {
|
||||
switch (stepLog.details.type) {
|
||||
case 'AI_AGENT':
|
||||
return <WorkflowRunStepLogsAiAgentDetail details={stepLog.details} />;
|
||||
case 'CODE':
|
||||
return <WorkflowRunStepLogsCodeDetail details={stepLog.details} />;
|
||||
case 'HTTP_REQUEST':
|
||||
return (
|
||||
<WorkflowRunStepLogsHttpRequestDetail details={stepLog.details} />
|
||||
);
|
||||
case 'EMAIL':
|
||||
return <WorkflowRunStepLogsEmailDetail details={stepLog.details} />;
|
||||
default:
|
||||
return (
|
||||
<JsonTree
|
||||
value={stepLog.details as JsonValue}
|
||||
shouldExpandNodeInitially={isTwoFirstDepths}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledRoot>
|
||||
{renderDetails()}
|
||||
|
||||
<WorkflowRunStepLogsEntries
|
||||
entries={stepLog.entries}
|
||||
onlyLatestIteration={isInsideIteratorLoop}
|
||||
/>
|
||||
|
||||
{isDefined(stepLog.truncated) && (
|
||||
<StyledTruncatedNotice>
|
||||
{t`Some log data was dropped to fit the size limit (${stepLog.truncated.droppedEntries} entries, ${stepLog.truncated.droppedBytes} bytes).`}
|
||||
</StyledTruncatedNotice>
|
||||
)}
|
||||
</StyledRoot>
|
||||
);
|
||||
};
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconMail,
|
||||
IconPaperclip,
|
||||
} from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import {
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
} from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters';
|
||||
import {
|
||||
StyledBadgeGroup,
|
||||
StyledBodyMeta,
|
||||
StyledEmptyHint,
|
||||
StyledErrorCard,
|
||||
StyledErrorMessageText,
|
||||
StyledHeaderLeft,
|
||||
StyledMetric,
|
||||
StyledMetricLabel,
|
||||
StyledMetricsRow,
|
||||
StyledMetricValue,
|
||||
StyledSection,
|
||||
StyledSectionTitle,
|
||||
StyledStatusBadge,
|
||||
StyledSummaryCard,
|
||||
StyledSummaryHeader,
|
||||
StyledTitle,
|
||||
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles';
|
||||
|
||||
const StyledModeBadge = styled.span`
|
||||
background: ${themeCssVariables.background.transparent.light};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const StyledSubject = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
|
||||
const StyledRecipientsCard = styled.div`
|
||||
background: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: grid;
|
||||
gap: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[3]};
|
||||
grid-template-columns: max-content 1fr;
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledRecipientLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const StyledRecipientValue = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
|
||||
const StyledBodyContainer = styled.div`
|
||||
background: ${themeCssVariables.background.tertiary};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const StyledBodyPre = styled.pre`
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
type EmailDetails = Extract<WorkflowRunStepLog['details'], { type: 'EMAIL' }>;
|
||||
|
||||
export const WorkflowRunStepLogsEmailDetail = ({
|
||||
details,
|
||||
}: {
|
||||
details: EmailDetails;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const isSuccess = details.status === 'SUCCESS';
|
||||
const StatusIcon = isSuccess ? IconCheck : IconAlertTriangle;
|
||||
const titleText = details.mode === 'SEND' ? t`Send email` : t`Draft email`;
|
||||
const statusLabel = isSuccess
|
||||
? details.mode === 'SEND'
|
||||
? t`Sent`
|
||||
: t`Drafted`
|
||||
: t`Failed`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledSummaryCard>
|
||||
<StyledSummaryHeader>
|
||||
<StyledHeaderLeft>
|
||||
<IconMail size={16} />
|
||||
<StyledTitle>{titleText}</StyledTitle>
|
||||
</StyledHeaderLeft>
|
||||
<StyledBadgeGroup>
|
||||
<StyledModeBadge>{details.mode}</StyledModeBadge>
|
||||
<StyledStatusBadge isSuccess={isSuccess}>
|
||||
<StatusIcon size={12} />
|
||||
{statusLabel}
|
||||
</StyledStatusBadge>
|
||||
</StyledBadgeGroup>
|
||||
</StyledSummaryHeader>
|
||||
|
||||
{isDefined(details.subject) && details.subject.length > 0 && (
|
||||
<StyledSubject>{details.subject}</StyledSubject>
|
||||
)}
|
||||
|
||||
<StyledMetricsRow>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconClock size={12} />
|
||||
{t`Duration`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>
|
||||
{formatDuration(details.durationMs)}
|
||||
</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
{isDefined(details.attachmentCount) &&
|
||||
details.attachmentCount > 0 && (
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconPaperclip size={12} />
|
||||
{t`Attachments`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{details.attachmentCount}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
)}
|
||||
</StyledMetricsRow>
|
||||
</StyledSummaryCard>
|
||||
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Recipients`}</StyledSectionTitle>
|
||||
<StyledRecipientsCard>
|
||||
<StyledRecipientLabel>{t`To`}</StyledRecipientLabel>
|
||||
<StyledRecipientValue>
|
||||
{isNonEmptyArray(details.recipients.to)
|
||||
? details.recipients.to.join(', ')
|
||||
: t`—`}
|
||||
</StyledRecipientValue>
|
||||
{isNonEmptyArray(details.recipients.cc) && (
|
||||
<>
|
||||
<StyledRecipientLabel>{t`Cc`}</StyledRecipientLabel>
|
||||
<StyledRecipientValue>
|
||||
{details.recipients.cc.join(', ')}
|
||||
</StyledRecipientValue>
|
||||
</>
|
||||
)}
|
||||
{isNonEmptyArray(details.recipients.bcc) && (
|
||||
<>
|
||||
<StyledRecipientLabel>{t`Bcc`}</StyledRecipientLabel>
|
||||
<StyledRecipientValue>
|
||||
{details.recipients.bcc.join(', ')}
|
||||
</StyledRecipientValue>
|
||||
</>
|
||||
)}
|
||||
</StyledRecipientsCard>
|
||||
</StyledSection>
|
||||
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Body`}</StyledSectionTitle>
|
||||
{isDefined(details.bodyPreview) && details.bodyPreview.length > 0 ? (
|
||||
<>
|
||||
<StyledBodyContainer>
|
||||
<StyledBodyPre>{details.bodyPreview}</StyledBodyPre>
|
||||
</StyledBodyContainer>
|
||||
{(isDefined(details.bodyBytes) || details.bodyTruncated) && (
|
||||
<StyledBodyMeta>
|
||||
{isDefined(details.bodyBytes) && formatBytes(details.bodyBytes)}
|
||||
{details.bodyTruncated && ' · truncated'}
|
||||
</StyledBodyMeta>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<StyledEmptyHint>{t`No body`}</StyledEmptyHint>
|
||||
)}
|
||||
</StyledSection>
|
||||
|
||||
{!isSuccess && isDefined(details.error) && (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Error`}</StyledSectionTitle>
|
||||
<StyledErrorCard>
|
||||
<StyledErrorMessageText>{details.error}</StyledErrorMessageText>
|
||||
</StyledErrorCard>
|
||||
</StyledSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily';
|
||||
import {
|
||||
StyledSection,
|
||||
StyledSectionTitle,
|
||||
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles';
|
||||
|
||||
const StyledEntriesList = styled.div`
|
||||
background: ${themeCssVariables.background.tertiary};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ${MONOSPACE_FONT_FAMILY};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledEntryRow = styled.div`
|
||||
align-items: baseline;
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
display: grid;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
grid-template-columns: max-content max-content 1fr;
|
||||
padding: ${themeCssVariables.spacing['0.5']} 0;
|
||||
`;
|
||||
|
||||
const StyledTimestamp = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
`;
|
||||
|
||||
const StyledLevelBadge = styled.span<{ level: string }>`
|
||||
background: ${({ level }) => {
|
||||
if (level === 'error')
|
||||
return themeCssVariables.background.transparent.danger;
|
||||
if (level === 'warn')
|
||||
return themeCssVariables.background.transparent.orange;
|
||||
if (level === 'info') return themeCssVariables.background.transparent.blue;
|
||||
return themeCssVariables.background.transparent.light;
|
||||
}};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${({ level }) => {
|
||||
if (level === 'error') return themeCssVariables.color.red;
|
||||
if (level === 'warn') return themeCssVariables.color.orange;
|
||||
if (level === 'info') return themeCssVariables.color.blue;
|
||||
return themeCssVariables.font.color.tertiary;
|
||||
}};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
padding: 0 ${themeCssVariables.spacing[1]};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const StyledMessage = styled.span`
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
const formatTimestamp = (timestamp: string): string => {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
return date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
export const WorkflowRunStepLogsEntries = ({
|
||||
entries,
|
||||
onlyLatestIteration = false,
|
||||
}: {
|
||||
entries: WorkflowRunStepLog['entries'];
|
||||
// Set when the parent step lives inside an iterator loop: each iteration
|
||||
// overwrites the same `stepLogs[stepId]` key, so what we render here is
|
||||
// only the latest iteration's entries — not a cumulative view.
|
||||
onlyLatestIteration?: boolean;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sectionTitle = onlyLatestIteration
|
||||
? t`Entries (${entries.length}, latest iteration only)`
|
||||
: t`Entries (${entries.length})`;
|
||||
|
||||
return (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{sectionTitle}</StyledSectionTitle>
|
||||
<StyledEntriesList>
|
||||
{entries.map((entry, index) => (
|
||||
<StyledEntryRow key={`${entry.timestamp}-${index}`}>
|
||||
<StyledTimestamp>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</StyledTimestamp>
|
||||
<StyledLevelBadge level={entry.level}>
|
||||
{entry.level}
|
||||
</StyledLevelBadge>
|
||||
<StyledMessage>{entry.message}</StyledMessage>
|
||||
</StyledEntryRow>
|
||||
))}
|
||||
</StyledEntriesList>
|
||||
</StyledSection>
|
||||
);
|
||||
};
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Fragment } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type WorkflowRunStepLog } from 'twenty-shared/workflow';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconClock,
|
||||
IconWorld,
|
||||
} from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily';
|
||||
import {
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
} from '@/workflow/workflow-run/observability/workflowRunStepLogsFormatters';
|
||||
import {
|
||||
StyledBadgeGroup,
|
||||
StyledBodyMeta,
|
||||
StyledEmptyHint,
|
||||
StyledErrorCard,
|
||||
StyledErrorMessageText,
|
||||
StyledHeaderLeft,
|
||||
StyledMetric,
|
||||
StyledMetricLabel,
|
||||
StyledMetricsRow,
|
||||
StyledMetricValue,
|
||||
StyledSection,
|
||||
StyledSectionTitle,
|
||||
StyledStatusBadge,
|
||||
StyledSummaryCard,
|
||||
StyledSummaryHeader,
|
||||
StyledTitle,
|
||||
} from '@/workflow/workflow-run/observability/workflowRunStepLogsStyles';
|
||||
|
||||
const StyledMethodBadge = styled.span<{ method: string }>`
|
||||
background: ${themeCssVariables.background.transparent.light};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-family: ${themeCssVariables.font.family};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const StyledNetworkErrorBadge = styled.span`
|
||||
align-items: center;
|
||||
background: ${themeCssVariables.background.transparent.danger};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${themeCssVariables.color.red};
|
||||
display: inline-flex;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledUrl = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-family: ${MONOSPACE_FONT_FAMILY};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
|
||||
const StyledHeaderTable = styled.div`
|
||||
background: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: grid;
|
||||
gap: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[3]};
|
||||
grid-template-columns: max-content 1fr;
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledHeaderName = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-family: ${MONOSPACE_FONT_FAMILY};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
`;
|
||||
|
||||
const StyledHeaderValue = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-family: ${MONOSPACE_FONT_FAMILY};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
|
||||
const StyledBodyPre = styled.pre`
|
||||
background: ${themeCssVariables.background.tertiary};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-family: ${MONOSPACE_FONT_FAMILY};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
margin: 0;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: ${themeCssVariables.spacing[2]};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
type HttpRequestDetails = Extract<
|
||||
WorkflowRunStepLog['details'],
|
||||
{ type: 'HTTP_REQUEST' }
|
||||
>;
|
||||
|
||||
const HeaderTable = ({ headers }: { headers: Record<string, string> }) => {
|
||||
const entries = Object.entries(headers);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledHeaderTable>
|
||||
{entries.map(([name, value]) => (
|
||||
<Fragment key={name}>
|
||||
<StyledHeaderName>{name}</StyledHeaderName>
|
||||
<StyledHeaderValue>{value}</StyledHeaderValue>
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledHeaderTable>
|
||||
);
|
||||
};
|
||||
|
||||
const BodyBlock = ({
|
||||
body,
|
||||
bodyBytes,
|
||||
bodyTruncated,
|
||||
emptyLabel,
|
||||
}: {
|
||||
body: string | undefined;
|
||||
bodyBytes: number | undefined;
|
||||
bodyTruncated: boolean | undefined;
|
||||
emptyLabel: string;
|
||||
}) => {
|
||||
if (!isDefined(body) || body.length === 0) {
|
||||
return <StyledEmptyHint>{emptyLabel}</StyledEmptyHint>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledBodyPre>{body}</StyledBodyPre>
|
||||
{(isDefined(bodyBytes) || bodyTruncated) && (
|
||||
<StyledBodyMeta>
|
||||
{isDefined(bodyBytes) && formatBytes(bodyBytes)}
|
||||
{bodyTruncated && ' · truncated'}
|
||||
</StyledBodyMeta>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkflowRunStepLogsHttpRequestDetail = ({
|
||||
details,
|
||||
}: {
|
||||
details: HttpRequestDetails;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const { request, response, durationMs, error } = details;
|
||||
|
||||
const isSuccess = isDefined(response)
|
||||
? response.status >= 200 && response.status < 400
|
||||
: false;
|
||||
const hasNetworkError = !isDefined(response);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledSummaryCard>
|
||||
<StyledSummaryHeader>
|
||||
<StyledHeaderLeft>
|
||||
<IconWorld size={16} />
|
||||
<StyledTitle>{t`HTTP request`}</StyledTitle>
|
||||
</StyledHeaderLeft>
|
||||
<StyledBadgeGroup>
|
||||
<StyledMethodBadge method={request.method}>
|
||||
{request.method}
|
||||
</StyledMethodBadge>
|
||||
{isDefined(response) ? (
|
||||
<StyledStatusBadge isSuccess={isSuccess}>
|
||||
{response.status}
|
||||
{isDefined(response.statusText) &&
|
||||
response.statusText.length > 0
|
||||
? ` ${response.statusText}`
|
||||
: ''}
|
||||
</StyledStatusBadge>
|
||||
) : (
|
||||
<StyledNetworkErrorBadge>
|
||||
<IconAlertTriangle size={12} />
|
||||
{t`Network error`}
|
||||
</StyledNetworkErrorBadge>
|
||||
)}
|
||||
</StyledBadgeGroup>
|
||||
</StyledSummaryHeader>
|
||||
<StyledUrl>{request.url}</StyledUrl>
|
||||
<StyledMetricsRow>
|
||||
<StyledMetric>
|
||||
<StyledMetricLabel>
|
||||
<IconClock size={12} />
|
||||
{t`Duration`}
|
||||
</StyledMetricLabel>
|
||||
<StyledMetricValue>{formatDuration(durationMs)}</StyledMetricValue>
|
||||
</StyledMetric>
|
||||
</StyledMetricsRow>
|
||||
</StyledSummaryCard>
|
||||
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>
|
||||
<IconArrowUp size={12} />
|
||||
{t`Request`}
|
||||
</StyledSectionTitle>
|
||||
<HeaderTable headers={request.headers} />
|
||||
<BodyBlock
|
||||
body={request.body}
|
||||
bodyBytes={request.bodyBytes}
|
||||
bodyTruncated={request.bodyTruncated}
|
||||
emptyLabel={t`No request body`}
|
||||
/>
|
||||
</StyledSection>
|
||||
|
||||
{isDefined(response) ? (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>
|
||||
<IconArrowDown size={12} />
|
||||
{t`Response`}
|
||||
</StyledSectionTitle>
|
||||
<HeaderTable headers={response.headers} />
|
||||
<BodyBlock
|
||||
body={response.body}
|
||||
bodyBytes={response.bodyBytes}
|
||||
bodyTruncated={response.bodyTruncated}
|
||||
emptyLabel={t`No response body`}
|
||||
/>
|
||||
</StyledSection>
|
||||
) : (
|
||||
hasNetworkError &&
|
||||
isDefined(error) && (
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>
|
||||
<IconAlertTriangle size={12} />
|
||||
{t`Error`}
|
||||
</StyledSectionTitle>
|
||||
<StyledErrorCard>
|
||||
<StyledErrorMessageText>{error}</StyledErrorMessageText>
|
||||
</StyledErrorCard>
|
||||
</StyledSection>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { type AiToolCallLog } from 'twenty-shared/workflow';
|
||||
|
||||
import { getToolDisplayMessage } from '@/ai/utils/getToolDisplayMessage';
|
||||
import { getToolIcon } from '@/ai/utils/getToolIcon';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type JsonValue } from 'type-fest';
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconCircleX,
|
||||
} from 'twenty-ui/display';
|
||||
import { JsonTree } from 'twenty-ui/json-visualizer';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ${themeCssVariables.font.family};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledToggleButton = styled.button<{ isExpandable: boolean }>`
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
cursor: ${({ isExpandable }) => (isExpandable ? 'pointer' : 'default')};
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
justify-content: space-between;
|
||||
padding: ${themeCssVariables.spacing[1]} 0;
|
||||
text-align: left;
|
||||
transition: color calc(${themeCssVariables.animation.duration.fast} * 1s)
|
||||
ease-in-out;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledLeftContent = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const StyledRightContent = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
svg {
|
||||
min-width: calc(${themeCssVariables.icon.size.sm} * 1px);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDisplayMessage = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledToolBadge = styled.span`
|
||||
background: ${themeCssVariables.background.transparent.light};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-family: ${themeCssVariables.font.family};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledContentContainer = styled.div`
|
||||
background: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
min-width: 0;
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledTabContainer = styled.div`
|
||||
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
margin-bottom: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledTab = styled.button<{ isActive: boolean }>`
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${({ isActive }) =>
|
||||
isActive
|
||||
? themeCssVariables.font.color.primary
|
||||
: themeCssVariables.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
font-weight: ${({ isActive }) =>
|
||||
isActive
|
||||
? themeCssVariables.font.weight.medium
|
||||
: themeCssVariables.font.weight.regular};
|
||||
padding: 0 0 ${themeCssVariables.spacing[2]};
|
||||
|
||||
&:hover {
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledJsonTreeContainer = styled.div`
|
||||
overflow-x: auto;
|
||||
|
||||
ul {
|
||||
min-width: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledErrorMessage = styled.div`
|
||||
color: ${themeCssVariables.color.red};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
type TabType = 'output' | 'input';
|
||||
|
||||
export const WorkflowRunStepLogsToolCallRow = ({
|
||||
toolCall,
|
||||
}: {
|
||||
toolCall: AiToolCallLog;
|
||||
}) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { t } = useLingui();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('output');
|
||||
|
||||
const hasError = toolCall.state === 'error';
|
||||
const hasOutput = isDefined(toolCall.output);
|
||||
const hasInput = isDefined(toolCall.input);
|
||||
const isExpandable = hasOutput || hasInput || hasError;
|
||||
|
||||
const ToolIcon = getToolIcon(toolCall.toolName);
|
||||
const StatusIcon = hasError ? IconCircleX : IconCheck;
|
||||
const statusColor = hasError
|
||||
? themeCssVariables.color.red
|
||||
: themeCssVariables.color.green;
|
||||
|
||||
const displayMessage = getToolDisplayMessage(
|
||||
toolCall.input ?? {},
|
||||
toolCall.toolName,
|
||||
true,
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledToggleButton
|
||||
type="button"
|
||||
isExpandable={isExpandable}
|
||||
onClick={() => isExpandable && setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<StyledLeftContent>
|
||||
<StyledIconContainer>
|
||||
<ToolIcon size={theme.icon.size.sm} />
|
||||
</StyledIconContainer>
|
||||
<StyledDisplayMessage>{displayMessage}</StyledDisplayMessage>
|
||||
</StyledLeftContent>
|
||||
<StyledRightContent>
|
||||
<StyledToolBadge>{toolCall.toolName}</StyledToolBadge>
|
||||
<StyledIconContainer style={{ color: statusColor }}>
|
||||
<StatusIcon size={theme.icon.size.sm} />
|
||||
</StyledIconContainer>
|
||||
{isExpandable &&
|
||||
(isExpanded ? (
|
||||
<IconChevronUp size={theme.icon.size.sm} />
|
||||
) : (
|
||||
<IconChevronDown size={theme.icon.size.sm} />
|
||||
))}
|
||||
</StyledRightContent>
|
||||
</StyledToggleButton>
|
||||
|
||||
{isExpandable && (
|
||||
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
|
||||
<StyledContentContainer>
|
||||
{hasError && isDefined(toolCall.errorMessage) ? (
|
||||
<StyledErrorMessage>{toolCall.errorMessage}</StyledErrorMessage>
|
||||
) : (
|
||||
<>
|
||||
<StyledTabContainer>
|
||||
<StyledTab
|
||||
type="button"
|
||||
isActive={activeTab === 'output'}
|
||||
onClick={() => setActiveTab('output')}
|
||||
>
|
||||
{t`Output`}
|
||||
</StyledTab>
|
||||
<StyledTab
|
||||
type="button"
|
||||
isActive={activeTab === 'input'}
|
||||
onClick={() => setActiveTab('input')}
|
||||
>
|
||||
{t`Input`}
|
||||
</StyledTab>
|
||||
</StyledTabContainer>
|
||||
|
||||
<StyledJsonTreeContainer>
|
||||
<JsonTree
|
||||
value={
|
||||
(activeTab === 'output'
|
||||
? (toolCall.output ?? t`No output`)
|
||||
: (toolCall.input ?? t`No input`)) as JsonValue
|
||||
}
|
||||
shouldExpandNodeInitially={() => false}
|
||||
emptyArrayLabel={t`Empty Array`}
|
||||
emptyObjectLabel={t`Empty Object`}
|
||||
emptyStringLabel={t`[empty string]`}
|
||||
arrowButtonCollapsedLabel={t`Expand`}
|
||||
arrowButtonExpandedLabel={t`Collapse`}
|
||||
onNodeValueClick={copyToClipboard}
|
||||
/>
|
||||
</StyledJsonTreeContainer>
|
||||
</>
|
||||
)}
|
||||
</StyledContentContainer>
|
||||
</AnimatedExpandableContainer>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
export const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { MONOSPACE_FONT_FAMILY } from '@/ui/theme/constants/MonospaceFontFamily';
|
||||
|
||||
export const StyledSummaryCard = styled.div`
|
||||
background: ${themeCssVariables.background.transparent.lighter};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
export const StyledSummaryHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export const StyledHeaderLeft = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
export const StyledTitle = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
export const StyledBadgeGroup = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
export const StyledStatusBadge = styled.span<{ isSuccess: boolean }>`
|
||||
align-items: center;
|
||||
background: ${({ isSuccess }) =>
|
||||
isSuccess
|
||||
? themeCssVariables.background.transparent.success
|
||||
: themeCssVariables.background.transparent.danger};
|
||||
border-radius: ${themeCssVariables.border.radius.xs};
|
||||
color: ${({ isSuccess }) =>
|
||||
isSuccess ? themeCssVariables.color.green : themeCssVariables.color.red};
|
||||
display: inline-flex;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
padding: ${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
export const StyledMetricsRow = styled.div`
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
`;
|
||||
|
||||
export const StyledMetric = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
export const StyledMetricLabel = styled.div`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
export const StyledMetricValue = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.lg};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
`;
|
||||
|
||||
export const StyledSection = styled.section`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
export const StyledSectionTitle = styled.h3`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
export const StyledErrorCard = styled.div`
|
||||
background: ${themeCssVariables.background.transparent.danger};
|
||||
border: 1px solid ${themeCssVariables.color.red};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
padding: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
export const StyledErrorMessageText = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-family: ${MONOSPACE_FONT_FAMILY};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
export const StyledEmptyHint = styled.div`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
export const StyledBodyMeta = styled.div`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
`;
|
||||
Reference in New Issue
Block a user