diff --git a/packages/noodl-editor/src/editor/src/router.setup.ts b/packages/noodl-editor/src/editor/src/router.setup.ts index 2213dc3..0e69437 100644 --- a/packages/noodl-editor/src/editor/src/router.setup.ts +++ b/packages/noodl-editor/src/editor/src/router.setup.ts @@ -19,6 +19,7 @@ import { DataLineagePanel } from './views/panels/DataLineagePanel'; import { DesignTokenPanel } from './views/panels/DesignTokenPanel/DesignTokenPanel'; import { EditorSettingsPanel } from './views/panels/EditorSettingsPanel/EditorSettingsPanel'; import { FileExplorerPanel } from './views/panels/FileExplorerPanel'; +import { ExecutionHistoryPanel } from './views/panels/ExecutionHistoryPanel'; import { GitHubPanel } from './views/panels/GitHubPanel'; import { NodeReferencesPanel_ID } from './views/panels/NodeReferencesPanel'; import { NodeReferencesPanel } from './views/panels/NodeReferencesPanel/NodeReferencesPanel'; @@ -158,6 +159,17 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) { panel: BackendServicesPanel }); + SidebarModel.instance.register({ + experimental: true, + id: 'execution-history', + name: 'Execution History', + description: 'View workflow execution history, inspect node data, and debug failed runs.', + isDisabled: isLesson === true, + order: 8.8, + icon: IconName.Bug, + panel: ExecutionHistoryPanel + }); + SidebarModel.instance.register({ id: 'app-setup', name: 'App Setup', diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.module.scss b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.module.scss new file mode 100644 index 0000000..1183ecf --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.module.scss @@ -0,0 +1,45 @@ +.Panel { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--theme-color-bg-2); + color: var(--theme-color-fg-default); +} + +.Header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--theme-color-bg-3); + flex-shrink: 0; +} + +.Title { + font-size: 13px; + font-weight: 600; + color: var(--theme-color-fg-default); +} + +.RefreshButton { + background: none; + border: none; + color: var(--theme-color-fg-default-shy); + font-size: 16px; + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; + line-height: 1; + + &:hover { + background-color: var(--theme-color-bg-3); + color: var(--theme-color-fg-default); + } +} + +.Body { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx new file mode 100644 index 0000000..e90596f --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/ExecutionHistoryPanel.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; + +import { ExecutionDetail } from './components/ExecutionDetail/ExecutionDetail'; +import { ExecutionFilters } from './components/ExecutionFilters/ExecutionFilters'; +import { ExecutionList } from './components/ExecutionList/ExecutionList'; +import styles from './ExecutionHistoryPanel.module.scss'; +import { type ExecutionFilters as FiltersState, useExecutionHistory } from './hooks/useExecutionHistory'; + +/** + * CF11-006: Execution History Panel + * + * Sidebar panel showing workflow execution history. + * Allows users to view past executions, inspect node data, and debug failures. + * + * Registered in router.setup.ts at order 8.8 (between backend-services and app-setup). + * CF11-007 will add canvas overlay integration via the onPinToCanvas prop. + */ +export function ExecutionHistoryPanel() { + const [selectedExecutionId, setSelectedExecutionId] = useState(null); + const [filters, setFilters] = useState({ + status: undefined, + startDate: undefined, + endDate: undefined + }); + + const { executions, loading, error, refresh } = useExecutionHistory(filters); + + return ( +
+
+ Execution History + +
+ + {!selectedExecutionId && } + +
+ {selectedExecutionId ? ( + setSelectedExecutionId(null)} + // onPinToCanvas will be wired in CF11-007 + /> + ) : ( + + )} +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/ExecutionDetail.module.scss b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/ExecutionDetail.module.scss new file mode 100644 index 0000000..0afcfee --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/ExecutionDetail.module.scss @@ -0,0 +1,172 @@ +.Detail { + display: flex; + flex-direction: column; + height: 100%; +} + +.Header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--theme-color-bg-3); + flex-shrink: 0; + background-color: var(--theme-color-bg-2); +} + +.BackButton, +.PinButton { + background: none; + border: 1px solid var(--theme-color-bg-3); + border-radius: 4px; + color: var(--theme-color-fg-default); + font-size: 12px; + padding: 4px 8px; + cursor: pointer; + flex-shrink: 0; + + &:hover { + background-color: var(--theme-color-bg-3); + } +} + +.Title { + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--theme-color-fg-default); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.Content { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.Summary { + background-color: var(--theme-color-bg-3); + border-radius: 4px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.SummaryRow { + display: flex; + align-items: center; + gap: 8px; +} + +.Label { + font-size: 11px; + color: var(--theme-color-fg-default-shy); + width: 64px; + flex-shrink: 0; +} + +.Value { + font-size: 12px; + color: var(--theme-color-fg-default); +} + +.StatusBadge { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 2px 6px; + border-radius: 3px; + + &[data-status='success'] { + color: var(--theme-color-success, #22c55e); + background-color: color-mix(in srgb, var(--theme-color-success, #22c55e) 15%, transparent); + } + + &[data-status='error'] { + color: var(--theme-color-danger, #ef4444); + background-color: color-mix(in srgb, var(--theme-color-danger, #ef4444) 15%, transparent); + } + + &[data-status='running'] { + color: var(--theme-color-notice, #f59e0b); + background-color: color-mix(in srgb, var(--theme-color-notice, #f59e0b) 15%, transparent); + } +} + +.Section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.SectionTitle { + margin: 0; + font-size: 12px; + font-weight: 600; + color: var(--theme-color-fg-default); + display: flex; + align-items: center; + gap: 6px; +} + +.StepCount { + font-size: 10px; + font-weight: 400; + color: var(--theme-color-fg-default-shy); + background-color: var(--theme-color-bg-3); + padding: 1px 5px; + border-radius: 10px; +} + +.ErrorPre, +.DataPre, +.StackPre { + margin: 0; + font-family: 'Courier New', Courier, monospace; + font-size: 11px; + color: var(--theme-color-fg-default); + background-color: var(--theme-color-bg-1); + border: 1px solid var(--theme-color-bg-3); + border-radius: 3px; + padding: 8px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 200px; + overflow-y: auto; +} + +.ErrorPre { + color: var(--theme-color-danger, #ef4444); + border-color: var(--theme-color-danger, #ef4444); +} + +.StackDetails { + summary { + font-size: 11px; + color: var(--theme-color-fg-default-shy); + cursor: pointer; + padding: 4px 0; + } +} + +.Loading, +.Error { + display: flex; + align-items: center; + justify-content: center; + padding: 48px 24px; + color: var(--theme-color-fg-default-shy); + font-size: 13px; +} + +.Error { + color: var(--theme-color-danger, #ef4444); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/ExecutionDetail.tsx b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/ExecutionDetail.tsx new file mode 100644 index 0000000..8462c9a --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/ExecutionDetail.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import styles from './ExecutionDetail.module.scss'; +import { NodeStepList } from './NodeStepList'; +import { useExecutionDetail } from '../../hooks/useExecutionDetail'; + +interface Props { + executionId: string; + onBack: () => void; + /** Hook for CF11-007 canvas overlay integration */ + onPinToCanvas?: (executionId: string) => void; +} + +function formatTime(timestamp: number): string { + return new Date(timestamp).toLocaleString(); +} + +function formatDuration(ms?: number): string { + if (ms === undefined || ms === null) return '—'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * Detailed view of a single workflow execution. + * Shows summary, error info, trigger data, and per-node steps. + */ +export function ExecutionDetail({ executionId, onBack, onPinToCanvas }: Props) { + const { execution, loading, error } = useExecutionDetail(executionId); + + if (loading) { + return ( +
+ Loading execution... +
+ ); + } + + if (error || !execution) { + return ( +
+ {error || 'Execution not found'} +
+ ); + } + + return ( +
+
+ + {execution.workflowName} + {onPinToCanvas && ( + + )} +
+ +
+
+
+ Status + + {execution.status} + +
+
+ Started + {formatTime(execution.startedAt)} +
+
+ Duration + {formatDuration(execution.durationMs)} +
+
+ Trigger + {execution.triggerType} +
+
+ + {execution.errorMessage && ( +
+

Error

+
{execution.errorMessage}
+ {execution.errorStack && ( +
+ Stack trace +
{execution.errorStack}
+
+ )} +
+ )} + + {execution.triggerData && Object.keys(execution.triggerData).length > 0 && ( +
+

Trigger Data

+
{JSON.stringify(execution.triggerData, null, 2)}
+
+ )} + +
+

+ Node Steps + {execution.steps?.length ?? 0} +

+ +
+
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepItem.module.scss b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepItem.module.scss new file mode 100644 index 0000000..7da7f0c --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepItem.module.scss @@ -0,0 +1,148 @@ +.Step { + border: 1px solid var(--theme-color-bg-3); + border-radius: 4px; + margin-bottom: 6px; + overflow: hidden; + background-color: var(--theme-color-bg-2); + + &[data-expanded='true'] { + border-color: var(--theme-color-primary); + } +} + +.Header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: default; + + &[role='button'] { + cursor: pointer; + + &:hover { + background-color: var(--theme-color-bg-3); + } + } +} + +.Index { + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--theme-color-bg-3); + color: var(--theme-color-fg-default-shy); + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.StepInfo { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.NodeName { + color: var(--theme-color-fg-default); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.NodeType { + color: var(--theme-color-fg-default-shy); + font-size: 10px; +} + +.StepMeta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.Duration { + color: var(--theme-color-fg-default-shy); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.StatusDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + + &[data-status='success'] { + background-color: var(--theme-color-success, #22c55e); + } + + &[data-status='error'] { + background-color: var(--theme-color-danger, #ef4444); + } + + &[data-status='running'] { + background-color: var(--theme-color-notice, #f59e0b); + } + + &[data-status='skipped'] { + background-color: var(--theme-color-fg-default-shy); + } +} + +.ExpandIcon { + color: var(--theme-color-fg-default-shy); + font-size: 9px; + flex-shrink: 0; +} + +.Body { + padding: 0 12px 12px; + background-color: var(--theme-color-bg-1); + border-top: 1px solid var(--theme-color-bg-3); + display: flex; + flex-direction: column; + gap: 10px; +} + +.ErrorSection { + padding-top: 10px; +} + +.DataSection { + padding-top: 10px; +} + +.SectionLabel { + display: block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--theme-color-fg-default-shy); + margin-bottom: 4px; +} + +.Pre { + margin: 0; + font-family: 'Courier New', Courier, monospace; + font-size: 11px; + color: var(--theme-color-fg-default); + background-color: var(--theme-color-bg-2); + border: 1px solid var(--theme-color-bg-3); + border-radius: 3px; + padding: 8px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 200px; + overflow-y: auto; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepItem.tsx b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepItem.tsx new file mode 100644 index 0000000..cd14d65 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepItem.tsx @@ -0,0 +1,80 @@ +import React, { useState } from 'react'; + +import type { ExecutionStep } from '@noodl-viewer-cloud/execution-history'; + +import styles from './NodeStepItem.module.scss'; + +interface Props { + step: ExecutionStep; + index: number; +} + +function formatDuration(ms?: number): string { + if (ms === undefined || ms === null) return '—'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function safeStringify(data: unknown): string { + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } +} + +/** + * Single node step row in the execution detail view. + * Expandable to show input/output data. + */ +export function NodeStepItem({ step, index }: Props) { + const [expanded, setExpanded] = useState(false); + + const hasData = step.inputData || step.outputData || step.errorMessage; + + return ( +
+
hasData && setExpanded((v) => !v)} + role={hasData ? 'button' : undefined} + tabIndex={hasData ? 0 : undefined} + onKeyDown={(e) => hasData && e.key === 'Enter' && setExpanded((v) => !v)} + > + {index + 1} +
+ {step.nodeName || step.nodeId} + {step.nodeType} +
+
+ {formatDuration(step.durationMs)} +
+
+ {hasData && {expanded ? '▲' : '▼'}} +
+ + {expanded && hasData && ( +
+ {step.errorMessage && ( +
+ Error +
{step.errorMessage}
+
+ )} + {step.inputData && ( +
+ Input +
{safeStringify(step.inputData)}
+
+ )} + {step.outputData && ( +
+ Output +
{safeStringify(step.outputData)}
+
+ )} +
+ )} +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepList.module.scss b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepList.module.scss new file mode 100644 index 0000000..4b3a912 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepList.module.scss @@ -0,0 +1,10 @@ +.List { + display: flex; + flex-direction: column; +} + +.Empty { + color: var(--theme-color-fg-default-shy); + font-size: 12px; + padding: 12px 0; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepList.tsx b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepList.tsx new file mode 100644 index 0000000..9852365 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionDetail/NodeStepList.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import type { ExecutionStep } from '@noodl-viewer-cloud/execution-history'; + +import { NodeStepItem } from './NodeStepItem'; +import styles from './NodeStepList.module.scss'; + +interface Props { + steps: ExecutionStep[]; +} + +/** Ordered list of node execution steps. */ +export function NodeStepList({ steps }: Props) { + if (steps.length === 0) { + return
No steps recorded
; + } + + return ( +
+ {steps.map((step, i) => ( + + ))} +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionFilters/ExecutionFilters.module.scss b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionFilters/ExecutionFilters.module.scss new file mode 100644 index 0000000..a06ed74 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionFilters/ExecutionFilters.module.scss @@ -0,0 +1,40 @@ +.Filters { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background-color: var(--theme-color-bg-3); + border-bottom: 1px solid var(--theme-color-bg-3); + flex-shrink: 0; +} + +.Select { + padding: 5px 8px; + border: 1px solid var(--theme-color-bg-3); + border-radius: 4px; + background-color: var(--theme-color-bg-1); + color: var(--theme-color-fg-default); + font-size: 12px; + flex: 1; + + &:focus { + outline: none; + border-color: var(--theme-color-primary); + } +} + +.ClearButton { + background: none; + border: 1px solid var(--theme-color-bg-3); + border-radius: 4px; + color: var(--theme-color-fg-default-shy); + font-size: 11px; + padding: 4px 8px; + cursor: pointer; + flex-shrink: 0; + + &:hover { + background-color: var(--theme-color-bg-2); + color: var(--theme-color-fg-default); + } +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionFilters/ExecutionFilters.tsx b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionFilters/ExecutionFilters.tsx new file mode 100644 index 0000000..844a285 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionFilters/ExecutionFilters.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import type { ExecutionStatus } from '@noodl-viewer-cloud/execution-history'; + +import type { ExecutionFilters as FiltersState } from '../../hooks/useExecutionHistory'; +import styles from './ExecutionFilters.module.scss'; + +interface Props { + filters: FiltersState; + onChange: (filters: FiltersState) => void; +} + +const STATUS_OPTIONS: { value: ExecutionStatus | ''; label: string }[] = [ + { value: '', label: 'All statuses' }, + { value: 'success', label: 'Success' }, + { value: 'error', label: 'Error' }, + { value: 'running', label: 'Running' } +]; + +/** Filter toolbar for the execution history list. */ +export function ExecutionFilters({ filters, onChange }: Props) { + return ( +
+ + + {(filters.status || filters.startDate || filters.endDate) && ( + + )} +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionItem.module.scss b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionItem.module.scss new file mode 100644 index 0000000..17802ff --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionItem.module.scss @@ -0,0 +1,81 @@ +.Item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background-color: var(--theme-color-bg-2); + border-bottom: 1px solid var(--theme-color-bg-3); + cursor: pointer; + transition: background-color 0.1s; + + &:hover { + background-color: var(--theme-color-bg-3); + } + + &:focus { + outline: 1px solid var(--theme-color-primary); + outline-offset: -1px; + } +} + +.StatusDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &[data-status='success'] { + background-color: var(--theme-color-success, #22c55e); + } + + &[data-status='error'] { + background-color: var(--theme-color-danger, #ef4444); + } + + &[data-status='running'] { + background-color: var(--theme-color-notice, #f59e0b); + } +} + +.Info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.Name { + color: var(--theme-color-fg-default); + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.Time { + color: var(--theme-color-fg-default-shy); + font-size: 11px; +} + +.Meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + flex-shrink: 0; +} + +.Duration { + color: var(--theme-color-fg-default); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.Trigger { + color: var(--theme-color-fg-default-shy); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionItem.tsx b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionItem.tsx new file mode 100644 index 0000000..2ff898f --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionItem.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import type { WorkflowExecution } from '@noodl-viewer-cloud/execution-history'; + +import styles from './ExecutionItem.module.scss'; + +interface Props { + execution: WorkflowExecution; + onSelect: () => void; +} + +function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return new Date(timestamp).toLocaleDateString(); +} + +function formatDuration(ms?: number): string { + if (ms === undefined || ms === null) return '—'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * Single row in the execution history list. + */ +export function ExecutionItem({ execution, onSelect }: Props) { + return ( +
e.key === 'Enter' && onSelect()} + > +
+
+ {execution.workflowName} + {formatRelativeTime(execution.startedAt)} +
+
+ {formatDuration(execution.durationMs)} + {execution.triggerType} +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionList.module.scss b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionList.module.scss new file mode 100644 index 0000000..280cc5e --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionList.module.scss @@ -0,0 +1,49 @@ +.List { + flex: 1; + overflow-y: auto; +} + +.Loading, +.Empty, +.Error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + gap: 6px; +} + +.Loading { + color: var(--theme-color-fg-default-shy); + font-size: 13px; +} + +.Empty { + color: var(--theme-color-fg-default-shy); +} + +.EmptyTitle { + font-size: 13px; + font-weight: 500; + color: var(--theme-color-fg-default); +} + +.EmptyHint { + font-size: 12px; + color: var(--theme-color-fg-default-shy); +} + +.Error { + padding: 12px 16px; + background-color: color-mix(in srgb, var(--theme-color-danger, #ef4444) 10%, transparent); + border: 1px solid var(--theme-color-danger, #ef4444); + border-radius: 4px; + margin: 12px 16px; + color: var(--theme-color-danger, #ef4444); + font-size: 13px; + align-items: flex-start; + justify-content: flex-start; + padding: 12px 16px; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionList.tsx b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionList.tsx new file mode 100644 index 0000000..e35d504 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/components/ExecutionList/ExecutionList.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import type { WorkflowExecution } from '@noodl-viewer-cloud/execution-history'; + +import { ExecutionItem } from './ExecutionItem'; +import styles from './ExecutionList.module.scss'; + +interface Props { + executions: WorkflowExecution[]; + loading: boolean; + error: string | null; + onSelect: (id: string) => void; +} + +/** + * Scrollable list of workflow executions. + */ +export function ExecutionList({ executions, loading, error, onSelect }: Props) { + if (loading) { + return ( +
+ Loading executions... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (executions.length === 0) { + return ( +
+ No executions yet + Workflow runs will appear here +
+ ); + } + + return ( +
+ {executions.map((execution) => ( + onSelect(execution.id)} /> + ))} +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/hooks/useExecutionDetail.ts b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/hooks/useExecutionDetail.ts new file mode 100644 index 0000000..c7c70d9 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/hooks/useExecutionDetail.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { ExecutionWithSteps } from '@noodl-viewer-cloud/execution-history'; + +export interface UseExecutionDetailResult { + execution: ExecutionWithSteps | null; + loading: boolean; + error: string | null; +} + +/** + * Fetches a single execution with all its steps via IPC. + */ +export function useExecutionDetail(executionId: string | null): UseExecutionDetailResult { + const [execution, setExecution] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetch = useCallback(async () => { + if (!executionId) { + setExecution(null); + return; + } + + setLoading(true); + setError(null); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { ipcRenderer } = (window as any).require('electron'); + const result = await ipcRenderer.invoke('execution-history:get', executionId); + + if (result && result.error) { + setError(result.error); + setExecution(null); + } else { + setExecution(result || null); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load execution detail'); + setExecution(null); + } finally { + setLoading(false); + } + }, [executionId]); + + useEffect(() => { + fetch(); + }, [fetch]); + + return { execution, loading, error }; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/hooks/useExecutionHistory.ts b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/hooks/useExecutionHistory.ts new file mode 100644 index 0000000..4bf5f2a --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/hooks/useExecutionHistory.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { ExecutionQuery, ExecutionStatus, WorkflowExecution } from '@noodl-viewer-cloud/execution-history'; + +export interface ExecutionFilters { + status?: ExecutionStatus; + startDate?: Date; + endDate?: Date; +} + +export interface UseExecutionHistoryResult { + executions: WorkflowExecution[]; + loading: boolean; + error: string | null; + refresh: () => void; +} + +/** + * Fetches execution history from the backend server via IPC. + * The backend server (TASK-007B) exposes a REST API on localhost. + */ +export function useExecutionHistory(filters: ExecutionFilters): UseExecutionHistoryResult { + const [executions, setExecutions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetch = useCallback(async () => { + setLoading(true); + setError(null); + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { ipcRenderer } = (window as any).require('electron'); + + const query: ExecutionQuery = { + status: filters.status, + startedAfter: filters.startDate?.getTime(), + startedBefore: filters.endDate?.getTime(), + limit: 100, + orderBy: 'started_at', + orderDir: 'desc' + }; + + const result = await ipcRenderer.invoke('execution-history:list', query); + + if (result && result.error) { + setError(result.error); + setExecutions([]); + } else { + setExecutions(result || []); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load execution history'); + setExecutions([]); + } finally { + setLoading(false); + } + }, [filters.status, filters.startDate, filters.endDate]); + + useEffect(() => { + fetch(); + }, [fetch]); + + return { executions, loading, error, refresh: fetch }; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/index.ts b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/index.ts new file mode 100644 index 0000000..96007d4 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/ExecutionHistoryPanel/index.ts @@ -0,0 +1 @@ +export { ExecutionHistoryPanel } from './ExecutionHistoryPanel'; diff --git a/packages/noodl-editor/tests/cloud/ExecutionHistoryPanel.test.ts b/packages/noodl-editor/tests/cloud/ExecutionHistoryPanel.test.ts new file mode 100644 index 0000000..eddc3b7 --- /dev/null +++ b/packages/noodl-editor/tests/cloud/ExecutionHistoryPanel.test.ts @@ -0,0 +1,127 @@ +/** + * CF11-006: Execution History Panel — Unit Tests + * + * Tests the pure utility functions used by the ExecutionHistoryPanel hooks. + * Hook integration tests require a running Electron renderer — covered by manual testing. + */ + +// ─── Utility functions under test (inlined to avoid React/IPC deps in test env) ─── + +function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return new Date(timestamp).toLocaleDateString(); +} + +function formatDuration(ms?: number): string { + if (ms === undefined || ms === null) return '—'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function buildExecutionQuery(filters: { + status?: string; + startDate?: Date; + endDate?: Date; +}) { + return { + status: filters.status, + startedAfter: filters.startDate?.getTime(), + startedBefore: filters.endDate?.getTime(), + limit: 100, + orderBy: 'started_at', + orderDir: 'desc' + }; +} + +// ─── Tests ─── + +describe('ExecutionHistoryPanel — formatDuration', () => { + it('returns em-dash for undefined', () => { + expect(formatDuration(undefined)).toBe('—'); + }); + + it('returns em-dash for null', () => { + expect(formatDuration(null as unknown as number)).toBe('—'); + }); + + it('formats sub-second durations as ms', () => { + expect(formatDuration(0)).toBe('0ms'); + expect(formatDuration(500)).toBe('500ms'); + expect(formatDuration(999)).toBe('999ms'); + }); + + it('formats 1000ms as 1.0s', () => { + expect(formatDuration(1000)).toBe('1.0s'); + }); + + it('formats multi-second durations', () => { + expect(formatDuration(2500)).toBe('2.5s'); + expect(formatDuration(10000)).toBe('10.0s'); + }); +}); + +describe('ExecutionHistoryPanel — formatRelativeTime', () => { + it('formats recent timestamps as seconds ago', () => { + const result = formatRelativeTime(Date.now() - 30000); // 30s ago + expect(result).toBe('30s ago'); + }); + + it('formats timestamps as minutes ago', () => { + const result = formatRelativeTime(Date.now() - 5 * 60 * 1000); // 5m ago + expect(result).toBe('5m ago'); + }); + + it('formats timestamps as hours ago', () => { + const result = formatRelativeTime(Date.now() - 3 * 60 * 60 * 1000); // 3h ago + expect(result).toBe('3h ago'); + }); + + it('formats old timestamps as locale date string', () => { + const oldDate = new Date('2025-01-01').getTime(); + const result = formatRelativeTime(oldDate); + // Should be a date string, not "Xh ago" + expect(result).not.toContain('ago'); + }); +}); + +describe('ExecutionHistoryPanel — buildExecutionQuery', () => { + it('builds query with no filters', () => { + const query = buildExecutionQuery({}); + expect(query.status).toBeUndefined(); + expect(query.startedAfter).toBeUndefined(); + expect(query.startedBefore).toBeUndefined(); + expect(query.limit).toBe(100); + expect(query.orderBy).toBe('started_at'); + expect(query.orderDir).toBe('desc'); + }); + + it('includes status filter when provided', () => { + const query = buildExecutionQuery({ status: 'error' }); + expect(query.status).toBe('error'); + }); + + it('converts Date objects to timestamps', () => { + const startDate = new Date('2025-01-01T00:00:00Z'); + const endDate = new Date('2025-12-31T23:59:59Z'); + const query = buildExecutionQuery({ startDate, endDate }); + expect(query.startedAfter).toBe(startDate.getTime()); + expect(query.startedBefore).toBe(endDate.getTime()); + }); + + it('always orders by started_at descending', () => { + const query = buildExecutionQuery({ status: 'success' }); + expect(query.orderBy).toBe('started_at'); + expect(query.orderDir).toBe('desc'); + }); + + it('always limits to 100 results', () => { + const query = buildExecutionQuery({}); + expect(query.limit).toBe(100); + }); +}); diff --git a/packages/noodl-editor/tests/cloud/index.ts b/packages/noodl-editor/tests/cloud/index.ts index 1cf73b7..a43104b 100644 --- a/packages/noodl-editor/tests/cloud/index.ts +++ b/packages/noodl-editor/tests/cloud/index.ts @@ -1 +1,2 @@ export * from './cloudformation'; +import './ExecutionHistoryPanel.test';