ui-nyla/src/components/UiComponents/Log/Log.tsx
2026-04-11 19:44:52 +02:00

284 lines
11 KiB
TypeScript

import React from 'react';
import { LogProps } from './LogTypes';
import { AutoScroll } from '../AutoScroll';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from './Log.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
// Helper to get status badge class
const getStatusBadgeClass = (status?: string | null): string => {
if (!status) return styles.statusBadge;
switch (status.toLowerCase()) {
case 'completed':
return `${styles.statusBadge} ${styles.statusCompleted}`;
case 'failed':
case 'error':
return `${styles.statusBadge} ${styles.statusFailed}`;
case 'running':
return `${styles.statusBadge} ${styles.statusRunning}`;
default:
return styles.statusBadge;
}
};
const Log: React.FC<LogProps> = ({
className = '',
emptyMessage = 'Keine Log-Informationen verfügbar',
dashboardTree,
onToggleOperationExpanded,
getChildOperations
}) => {
const { t } = useLanguage();
const resolvedEmptyMessage = typeof emptyMessage === 'string' ? t(emptyMessage, emptyMessage) : emptyMessage;
const formatLogTimestamp = (timestamp: number): string => {
try {
const formatted = formatUnixTimestamp(timestamp, undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
return formatted.time;
} catch {
return new Date(timestamp * 1000).toLocaleString();
}
};
// Render operation node recursively
const renderOperationNode = (operationId: string, depth: number = 0): React.ReactNode => {
if (!dashboardTree || !getChildOperations) {
return null;
}
const operation = dashboardTree.operations.get(operationId);
if (!operation) {
return null;
}
// Get logs for this operation, sorted by timestamp
const logsArray = Array.from(operation.logs.values()).sort((a, b) => {
const tsA = a.timestamp || 0;
const tsB = b.timestamp || 0;
return tsA - tsB; // Ascending order (oldest first)
});
// Get latest log for timestamp
const latestLog = logsArray.length > 0 ? logsArray[logsArray.length - 1] : null;
// Skip rendering if no logs yet
if (logsArray.length === 0) {
return null;
}
// Get child operations
const childOperations = getChildOperations(operationId);
const hasChildren = childOperations.length > 0;
const hasLogs = logsArray.length > 0;
const hasContentToExpand = hasChildren || hasLogs;
// Calculate progress percentage
let progressPercentage = 0;
if (operation.latestProgress !== null && operation.latestProgress !== undefined) {
progressPercentage = Math.min(Math.max(operation.latestProgress * 100, 0), 100);
}
// Force 100% progress when status is 'completed'
if (operation.latestStatus === 'completed') {
progressPercentage = 100;
}
// Use stable operation name (from first log) or fallback to operationId
const operationName = operation.operationName || `${t('Operation')} ${operationId}`;
// Use latest message as status tag (updates with each poll)
const latestMessage = operation.latestMessage || '';
const operationStatus = operation.latestStatus || 'running';
const operationTimestamp = latestLog?.timestamp;
// Calculate consistent indentation per level (24px per level)
const hasIndent = depth > 0;
// Calculate log entry indentation to align with operation name
// Operation name starts at: header padding-left (12px) + button/spacer (20px) + gap (8px) = 40px from operationContent
// operationLogsList has margin-left: 12px (for border), so log entries are at: container marginLeft + 12px
// We want log entries at 40px from operationContent, so: container marginLeft + 12px = 40px
// Therefore: container marginLeft = 28px from operationContent
// But operationNode has paddingLeft: 12px for indented nodes, 0 for root
// So from operationNode: container marginLeft = 28px - operationNode.paddingLeft
// Root: 28px - 0 = 28px
// Indented: 28px - 12px = 16px
const logIndentPx = hasIndent ? 16 : 28;
// Calculate header indentation to match message indentation
// Headers are inside operationNode which has paddingLeft: 12px (for indented)
// Messages container has marginLeft: logIndentPx from operationNode, and list has margin-left: 12px
// So messages start at: logIndentPx + 12px from operationNode's left edge
// Headers start at: operationNode marginLeft + paddingLeft = headerIndentPx + 12px
// To align: headerIndentPx + 12px = logIndentPx + 12px, so headerIndentPx = logIndentPx
// But headers have their own padding (12px), so header content starts at headerIndentPx + 12px + 12px
// Messages start at logIndentPx + 12px, so we need headerIndentPx = logIndentPx - 12px to align content
// Actually, we want the header box to align with messages, so headerIndentPx should account for header padding
const headerIndentPx = logIndentPx; // Headers and messages both use logIndentPx, paddingLeft handles alignment
return (
<div
key={operationId}
className={`${styles.operationNode} ${hasIndent ? styles.operationNodeIndented : ''}`}
style={{
marginLeft: `${headerIndentPx}px`,
paddingLeft: hasIndent ? '12px' : '0',
position: 'relative'
}}
>
<div className={styles.operationRow}>
{/* Operation content */}
<div className={styles.operationContent}>
<div className={styles.operationHeader}>
<div className={styles.operationHeaderRow}>
{hasContentToExpand && (
<button
className={styles.expandButton}
onClick={() => onToggleOperationExpanded?.(operationId)}
aria-label={operation.expanded ? t('Einklappen') : t('Ausklappen')}
>
<span className={`${styles.collapseIcon} ${operation.expanded ? '' : styles.collapsed}`}>
</span>
</button>
)}
{!hasContentToExpand && <span className={styles.expandButtonSpacer} />}
<span className={styles.operationName}>{operationName}</span>
{/* Latest status message tag (updates with each poll) */}
{latestMessage && (
<span className={styles.statusMessageTag}>
{latestMessage}
</span>
)}
{operationTimestamp && (
<span className={styles.operationTimestamp}>
{formatLogTimestamp(operationTimestamp)}
</span>
)}
<span className={getStatusBadgeClass(operationStatus)}>
{operationStatus}
</span>
{progressPercentage > 0 && (
<span className={styles.progressPercentage}>
{Math.round(progressPercentage)}%
</span>
)}
</div>
{progressPercentage > 0 && (
<div className={styles.progressBarContainer}>
<div
className={`${styles.progressBar} ${progressPercentage >= 100 ? styles.progressCompleted : ''}`}
style={{ width: `${progressPercentage}%` }}
/>
</div>
)}
</div>
</div>
</div>
{/* Show logs and children when expanded */}
{operation.expanded && (
<>
{/* Log messages for this operation - show only latest log */}
{latestLog && (
<div
className={styles.operationLogsContainer}
style={{
// Messages should align with header content (not header box edge)
// Header content starts at: operationNode paddingLeft (12px) + header padding (12px) = 24px from operationNode left
// Messages should start at the same position: 0 marginLeft (since operationNode already has paddingLeft)
marginLeft: '0px'
}}
>
<div className={styles.operationLogsList}>
<div key={`log-${operationId}-latest`} className={styles.logEntry}>
<div className={styles.logEntryHeader}>
<span className={styles.logTimestamp}>
{formatLogTimestamp(latestLog.timestamp)}
</span>
<span className={styles.logEntryMessage}>
{latestLog.message}
</span>
{latestLog.status && (
<span className={getStatusBadgeClass(latestLog.status)}>
{latestLog.status}
</span>
)}
{latestLog.progress !== undefined && latestLog.progress !== null && (
<span className={styles.logProgress}>
{Math.round(latestLog.progress * 100)}%
</span>
)}
</div>
</div>
</div>
</div>
)}
{/* Child operations */}
{hasChildren && (
<div className={styles.operationChildren}>
{childOperations.map((childOpId) => renderOperationNode(childOpId, depth + 1))}
</div>
)}
</>
)}
</div>
);
};
// Render dashboard tree
const renderDashboard = (): React.ReactNode => {
if (!dashboardTree || !getChildOperations) {
return null;
}
if (dashboardTree.rootOperations.length === 0) {
return (
<div className={styles.emptyState}>{resolvedEmptyMessage}</div>
);
}
return (
<div className={styles.dashboardContainer}>
{dashboardTree.rootOperations.map((rootOpId) => renderOperationNode(rootOpId, 0))}
</div>
);
};
// Check if we have dashboard logs to display
const hasDashboardLogs = dashboardTree && dashboardTree.rootOperations.length > 0;
if (!hasDashboardLogs) {
return (
<div className={`${styles.logContainer} ${className}`}>
<div className={styles.emptyState}>{resolvedEmptyMessage}</div>
</div>
);
}
return (
<div className={`${styles.logContainer} ${className}`}>
<AutoScroll scrollDependency={dashboardTree.rootOperations.length}>
<div className={styles.scrollableContent}>
<div className={styles.dashboardSection}>
{renderDashboard()}
</div>
</div>
</AutoScroll>
</div>
);
};
export default Log;