284 lines
11 KiB
TypeScript
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;
|