feature: show workflow stats

This commit is contained in:
Ida Dittrich 2026-01-02 12:02:25 +01:00
parent 401c0885a1
commit ae6a634274
6 changed files with 125 additions and 57 deletions

View file

@ -107,6 +107,34 @@
text-align: right;
}
.statsContainer {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.statItem {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
}
.statLabel {
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 11px;
}
.statValue {
font-weight: 600;
color: var(--color-text);
font-family: 'Courier New', monospace;
}
/* Dark theme support */
[data-theme="dark"] .workflowStatusContainer {
background-color: var(--color-surface-dark);
@ -151,3 +179,11 @@
color: var(--color-text-dark);
}
[data-theme="dark"] .statLabel {
color: var(--color-text-secondary-dark);
}
[data-theme="dark"] .statValue {
color: var(--color-text-dark);
}

View file

@ -88,43 +88,28 @@ const extractWorkflowStatus = (logs: any[]): { status: WorkflowStatusType; round
};
};
// Helper function to group logs by round and get latest progress
const getLatestRoundProgress = (logs: any[]): { round: number | null; progress: number | undefined } => {
if (!logs || logs.length === 0) {
return { round: null, progress: undefined };
// Helper function to format bytes to KB or MB
const formatBytes = (bytes?: number): string => {
if (bytes === undefined || bytes === null) return '-';
if (bytes === 0) return '0 B';
const kb = bytes / 1024;
if (kb < 1024) {
return `${kb.toFixed(2)} KB`;
}
const mb = kb / 1024;
return `${mb.toFixed(2)} MB`;
};
// Find the latest round
let currentRound = 1;
let latestProgress: number | undefined = undefined;
let latestRound = 1;
// Helper function to format price
const formatPrice = (price?: number): string => {
if (price === undefined || price === null) return '-';
return `$${price.toFixed(2)}`;
};
const sortedLogs = [...logs].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
sortedLogs.forEach((log) => {
const message = (log.message || '').toLowerCase();
// Check if this is a workflow status message that indicates a round change
if (message.includes('workflow started') || message.includes('workflow resumed')) {
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
if (roundMatch) {
currentRound = parseInt(roundMatch[1], 10);
latestRound = currentRound;
} else if (message.includes('workflow started')) {
currentRound = 1;
latestRound = 1;
}
}
// Update progress for current round
if (log.progress !== undefined && log.progress !== null) {
if (currentRound === latestRound) {
latestProgress = log.progress;
}
}
});
return { round: latestRound, progress: latestProgress };
// Helper function to format processing time
const formatProcessingTime = (time?: number): string => {
if (time === undefined || time === null) return '-';
return `${time.toFixed(2)}s`;
};
const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
@ -132,7 +117,8 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
logs = [],
workflowStatus: workflowStatusFromApi,
currentRound: currentRoundFromApi,
isRunning
isRunning,
latestStats
}) => {
// Use workflow status and round from API response, fallback to extracting from logs
const workflowStatus = useMemo(() => {
@ -173,21 +159,12 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
return extractWorkflowStatus(logs);
}, [workflowStatusFromApi, currentRoundFromApi, logs]);
// Get latest round progress
const latestProgress = useMemo(() => getLatestRoundProgress(logs), [logs]);
// Determine if workflow is running (show spinner)
// Show spinner if explicitly running OR if status indicates running state
const showSpinner = isRunning === true || workflowStatus.status === 'started' || workflowStatus.status === 'resumed';
// Calculate progress percentage
const progressValue = latestProgress.progress !== undefined
? Math.min(Math.max(latestProgress.progress, 0), 1)
: undefined;
const progressPercent = progressValue !== undefined ? Math.round(progressValue * 100) : undefined;
// Don't render if no status information (but always show if spinner should be visible)
if (!showSpinner && !workflowStatus.status && workflowStatus.round === null && progressValue === undefined) {
// Don't render if no status information and no stats (but always show if spinner should be visible)
if (!showSpinner && !workflowStatus.status && workflowStatus.round === null && !latestStats) {
return null;
}
@ -208,16 +185,33 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
)}
</div>
{/* Progress Bar */}
{progressValue !== undefined && (
<div className={styles.progressBarContainer}>
<div className={styles.progressBar}>
<div
className={styles.progressBarFill}
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className={styles.progressBarLabel}>{progressPercent}%</div>
{/* Stats Display */}
{latestStats && (
<div className={styles.statsContainer}>
{latestStats.priceUsd !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Price:</span>
<span className={styles.statValue}>{formatPrice(latestStats.priceUsd)}</span>
</div>
)}
{latestStats.processingTime !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Time:</span>
<span className={styles.statValue}>{formatProcessingTime(latestStats.processingTime)}</span>
</div>
)}
{latestStats.bytesSent !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Sent:</span>
<span className={styles.statValue}>{formatBytes(latestStats.bytesSent)}</span>
</div>
)}
{latestStats.bytesReceived !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Received:</span>
<span className={styles.statValue}>{formatBytes(latestStats.bytesReceived)}</span>
</div>
)}
</div>
)}
</div>

View file

@ -44,6 +44,16 @@ export interface WorkflowStatusProps {
* Whether the workflow is currently running (shows spinner)
*/
isRunning?: boolean;
/**
* Latest statistics from the workflow (price, processing time, bytes sent/received)
*/
latestStats?: {
priceUsd?: number;
processingTime?: number;
bytesSent?: number;
bytesReceived?: number;
} | null;
}
export type WorkflowStatusType = 'started' | 'resumed' | 'stopped' | 'failed' | 'completed' | null;

View file

@ -1471,6 +1471,7 @@ const PageRenderer: React.FC<PageRendererProps> = ({
workflowStatus={hookData?.workflowStatus}
currentRound={hookData?.currentRound || hookData?.workflowData?.currentRound}
isRunning={hookData?.isRunning || false}
latestStats={hookData?.latestStats || null}
/>
</div>
)}

View file

@ -48,6 +48,7 @@ export function useDashboardInputForm() {
logs,
dashboardLogs,
unifiedContentLogs,
latestStats,
startWorkflow,
stopWorkflow,
resetWorkflow,
@ -755,7 +756,8 @@ export function useDashboardInputForm() {
setIsFileAttachmentPopupOpen,
allUserFiles: fileContext.files || [],
handleFileAttach,
handleFileUploadAndAttach
handleFileUploadAndAttach,
latestStats
};
}

View file

@ -27,6 +27,7 @@ export function useWorkflowLifecycle() {
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
const [statusChangedFromRunningAt, setStatusChangedFromRunningAt] = useState<number | null>(null);
const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null);
const prevStatusRef = useRef<string>('idle');
const statusRef = useRef<string>('idle');
const statusChangedFromRunningAtRef = useRef<number | null>(null);
@ -266,6 +267,25 @@ export function useWorkflowLifecycle() {
return [...allLogs].sort(sortLogs);
});
// Process stats and keep the latest one (highest createdAt)
const statsItems = timeline.filter(item => item.type === 'stat');
if (statsItems.length > 0) {
// Sort by createdAt descending to get the latest
const sortedStats = [...statsItems].sort((a, b) => b.createdAt - a.createdAt);
const latestStatItem = sortedStats[0];
const statData = latestStatItem.item || latestStatItem;
if (statData && (statData.priceUsd !== undefined || statData.processingTime !== undefined ||
statData.bytesSent !== undefined || statData.bytesReceived !== undefined)) {
setLatestStats({
priceUsd: statData.priceUsd,
processingTime: statData.processingTime,
bytesSent: statData.bytesSent,
bytesReceived: statData.bytesReceived
});
}
}
}, [convertLogToFrontendFormat]);
// Poll workflow data using unified chat data endpoint
@ -346,6 +366,7 @@ export function useWorkflowLifecycle() {
setLogs([]);
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
return;
}
@ -404,6 +425,7 @@ export function useWorkflowLifecycle() {
setLogs(prev => prev.length > 0 ? [] : prev);
setDashboardLogs(prev => prev.length > 0 ? [] : prev);
setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev);
setLatestStats(null);
setCurrentRound(prev => prev !== undefined ? undefined : prev);
if (statusChangedFromRunningAt !== null) {
setStatusChangedFromRunningAt(null);
@ -493,6 +515,7 @@ export function useWorkflowLifecycle() {
statusRef.current = 'idle';
updateWorkflowStatus('idle');
setCurrentRound(undefined);
setLatestStats(null);
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
lastRenderedTimestampRef.current = null;
@ -512,6 +535,7 @@ export function useWorkflowLifecycle() {
setLogs([]);
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
updateWorkflowStatus('idle');
return;
}
@ -579,6 +603,7 @@ export function useWorkflowLifecycle() {
logs,
dashboardLogs,
unifiedContentLogs,
latestStats,
startWorkflow: handleStartWorkflow,
stopWorkflow: handleStopWorkflow,
resetWorkflow,