1.0.0-rc3 rc3
This commit is contained in:
@@ -97,6 +97,10 @@ const HISTORY_WINDOWS = [
|
||||
{ label: '4d', hours: 96 }
|
||||
];
|
||||
const HISTORY_STACK_BREAKPOINT = 1700;
|
||||
const HISTORY_RESAMPLE_TARGET_POINTS = 720;
|
||||
const HISTORY_INTERPOLATION_MIN_GAP_MS = 5 * 60 * 1000;
|
||||
const HISTORY_INTERPOLATION_MAX_GAP_MS = 45 * 60 * 1000;
|
||||
const HISTORY_INTERPOLATION_GAP_FACTOR = 6;
|
||||
|
||||
const CPU_SERIES_COLOR = '#c43d2f';
|
||||
const GPU_SERIES_COLOR = '#c9961a';
|
||||
@@ -113,6 +117,15 @@ const historyChartOptions = {
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const first = Array.isArray(items) ? items[0] : null;
|
||||
const rawLabel = first?.label ?? first?.raw?.x ?? first?.parsed?.x ?? '';
|
||||
return formatTickLabel(rawLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
@@ -120,7 +133,17 @@ const historyChartOptions = {
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8,
|
||||
color: '#6a4d38'
|
||||
color: '#6a4d38',
|
||||
callback: function (value, index, ticks) {
|
||||
const resolvedLabel = typeof this?.getLabelForValue === 'function'
|
||||
? this.getLabelForValue(value)
|
||||
: (
|
||||
Array.isArray(ticks) && ticks[index]
|
||||
? (ticks[index].label ?? ticks[index].value ?? value)
|
||||
: value
|
||||
);
|
||||
return formatTickLabel(resolvedLabel);
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(111,57,34,0.08)'
|
||||
@@ -152,6 +175,182 @@ function formatTickLabel(timestampIso) {
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDayMarkerLabel(timestampIso) {
|
||||
const parsed = Date.parse(String(timestampIso || ''));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(parsed);
|
||||
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.`;
|
||||
}
|
||||
|
||||
function toDayKeyFromTimestamp(timestampIso) {
|
||||
const parsed = Date.parse(String(timestampIso || ''));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(parsed);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function parseTimestampMs(value) {
|
||||
const parsed = Date.parse(String(value || ''));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeHistoryPoints(points = []) {
|
||||
return (Array.isArray(points) ? points : [])
|
||||
.map((point) => {
|
||||
const timestampMs = parseTimestampMs(point?.capturedAt);
|
||||
if (!Number.isFinite(timestampMs)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
capturedAt: new Date(timestampMs).toISOString(),
|
||||
timestampMs,
|
||||
cpuUsagePercent: toNumberOrNull(point?.cpuUsagePercent),
|
||||
ramUsagePercent: toNumberOrNull(point?.ramUsagePercent),
|
||||
gpuUsagePercent: toNumberOrNull(point?.gpuUsagePercent),
|
||||
cpuTemperatureC: toNumberOrNull(point?.cpuTemperatureC),
|
||||
gpuTemperatureC: toNumberOrNull(point?.gpuTemperatureC)
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.timestampMs - right.timestampMs);
|
||||
}
|
||||
|
||||
function interpolateMetric(prevPoint, nextPoint, metricKey, targetMs, maxGapMs) {
|
||||
if (!prevPoint || !nextPoint) {
|
||||
return null;
|
||||
}
|
||||
if (prevPoint.timestampMs === nextPoint.timestampMs) {
|
||||
return toNumberOrNull(prevPoint[metricKey]);
|
||||
}
|
||||
const gapMs = nextPoint.timestampMs - prevPoint.timestampMs;
|
||||
if (!Number.isFinite(gapMs) || gapMs <= 0 || gapMs > maxGapMs) {
|
||||
return null;
|
||||
}
|
||||
const prevValue = toNumberOrNull(prevPoint[metricKey]);
|
||||
const nextValue = toNumberOrNull(nextPoint[metricKey]);
|
||||
if (!Number.isFinite(prevValue) || !Number.isFinite(nextValue)) {
|
||||
return null;
|
||||
}
|
||||
const ratio = (targetMs - prevPoint.timestampMs) / gapMs;
|
||||
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
|
||||
return null;
|
||||
}
|
||||
return Number((prevValue + ((nextValue - prevValue) * ratio)).toFixed(2));
|
||||
}
|
||||
|
||||
function buildResampledHistoryPoints(points = [], historyHours = 24) {
|
||||
const normalized = normalizeHistoryPoints(points);
|
||||
if (normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const historyWindowMs = Math.max(1, Number(historyHours || 24)) * 60 * 60 * 1000;
|
||||
const stepMsRaw = Math.round(historyWindowMs / HISTORY_RESAMPLE_TARGET_POINTS);
|
||||
const stepMs = Math.max(60 * 1000, stepMsRaw);
|
||||
const maxInterpolationGapMs = Math.max(
|
||||
HISTORY_INTERPOLATION_MIN_GAP_MS,
|
||||
Math.min(HISTORY_INTERPOLATION_MAX_GAP_MS, stepMs * HISTORY_INTERPOLATION_GAP_FACTOR)
|
||||
);
|
||||
|
||||
const latestSourceMs = normalized[normalized.length - 1].timestampMs;
|
||||
const windowEndMs = Math.max(Date.now(), latestSourceMs);
|
||||
const windowStartMs = windowEndMs - historyWindowMs;
|
||||
const gridStartMs = Math.floor(windowStartMs / stepMs) * stepMs;
|
||||
const gridEndMs = Math.ceil(windowEndMs / stepMs) * stepMs;
|
||||
const source = normalized.filter((point) => point.timestampMs >= (gridStartMs - maxInterpolationGapMs));
|
||||
|
||||
const keys = [
|
||||
'cpuUsagePercent',
|
||||
'ramUsagePercent',
|
||||
'gpuUsagePercent',
|
||||
'cpuTemperatureC',
|
||||
'gpuTemperatureC'
|
||||
];
|
||||
|
||||
let sourceIndex = 0;
|
||||
const output = [];
|
||||
for (let ts = gridStartMs; ts <= gridEndMs; ts += stepMs) {
|
||||
while (sourceIndex < source.length && source[sourceIndex].timestampMs < ts) {
|
||||
sourceIndex += 1;
|
||||
}
|
||||
const nextPoint = sourceIndex < source.length ? source[sourceIndex] : null;
|
||||
const prevPoint = sourceIndex > 0 ? source[sourceIndex - 1] : null;
|
||||
|
||||
const row = {
|
||||
capturedAt: new Date(ts).toISOString(),
|
||||
timestampMs: ts
|
||||
};
|
||||
|
||||
for (const key of keys) {
|
||||
const nextValue = nextPoint && nextPoint.timestampMs === ts
|
||||
? toNumberOrNull(nextPoint[key])
|
||||
: null;
|
||||
const prevValue = prevPoint && prevPoint.timestampMs === ts
|
||||
? toNumberOrNull(prevPoint[key])
|
||||
: null;
|
||||
row[key] = Number.isFinite(nextValue)
|
||||
? nextValue
|
||||
: (Number.isFinite(prevValue)
|
||||
? prevValue
|
||||
: interpolateMetric(prevPoint, nextPoint, key, ts, maxInterpolationGapMs));
|
||||
}
|
||||
output.push(row);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const historyDayBoundaryPlugin = {
|
||||
id: 'historyDayBoundary',
|
||||
afterDatasetsDraw(chart, _args, options) {
|
||||
if (options === false) {
|
||||
return;
|
||||
}
|
||||
const xScale = chart?.scales?.x;
|
||||
const area = chart?.chartArea;
|
||||
const labels = Array.isArray(chart?.data?.labels) ? chart.data.labels : [];
|
||||
if (!xScale || !area || labels.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = chart.ctx;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(111,57,34,0.30)';
|
||||
ctx.fillStyle = 'rgba(111,57,34,0.75)';
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
for (let index = 1; index < labels.length; index += 1) {
|
||||
const prevDayKey = toDayKeyFromTimestamp(labels[index - 1]);
|
||||
const nextDayKey = toDayKeyFromTimestamp(labels[index]);
|
||||
if (!prevDayKey || !nextDayKey || prevDayKey === nextDayKey) {
|
||||
continue;
|
||||
}
|
||||
const xPrev = xScale.getPixelForValue(index - 1);
|
||||
const xNext = xScale.getPixelForValue(index);
|
||||
const x = (xPrev + xNext) / 2;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, area.top);
|
||||
ctx.lineTo(x, area.bottom);
|
||||
ctx.stroke();
|
||||
|
||||
const markerLabel = formatDayMarkerLabel(labels[index]);
|
||||
if (markerLabel) {
|
||||
ctx.fillText(markerLabel, x + 4, area.top + 4);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
function buildSeriesToggleButtonStyle(color, active) {
|
||||
if (active) {
|
||||
return {
|
||||
@@ -258,6 +457,10 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
const ramTitle = String(memory?.name || memory?.vendor || memory?.model || '').trim() || 'Arbeitsspeicher';
|
||||
const gpuTitle = String(primaryGpu?.name || gpu?.name || gpu?.vendor || '').trim() || 'GPU';
|
||||
const historyPoints = Array.isArray(historyState.points) ? historyState.points : [];
|
||||
const resampledHistoryPoints = useMemo(
|
||||
() => buildResampledHistoryPoints(historyPoints, historyHours),
|
||||
[historyPoints, historyHours]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!monitoringState.enabled) {
|
||||
@@ -338,12 +541,12 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const historyUsageChartData = useMemo(() => ({
|
||||
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
|
||||
const historyUsageChartData = useMemo(() => ({
|
||||
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU',
|
||||
data: historyPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuUsagePercent)),
|
||||
borderColor: CPU_SERIES_COLOR,
|
||||
backgroundColor: CPU_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
@@ -353,7 +556,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
},
|
||||
{
|
||||
label: 'RAM',
|
||||
data: historyPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.ramUsagePercent)),
|
||||
borderColor: RAM_SERIES_COLOR,
|
||||
backgroundColor: RAM_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
@@ -363,7 +566,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
},
|
||||
{
|
||||
label: 'GPU',
|
||||
data: historyPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuUsagePercent)),
|
||||
borderColor: GPU_SERIES_COLOR,
|
||||
backgroundColor: GPU_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
@@ -372,14 +575,14 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
tension: 0.22
|
||||
}
|
||||
]
|
||||
}), [historyPoints, usageSeriesVisible]);
|
||||
}), [resampledHistoryPoints, usageSeriesVisible]);
|
||||
|
||||
const historyTempChartData = useMemo(() => ({
|
||||
labels: historyPoints.map((point) => formatTickLabel(point?.capturedAt)),
|
||||
const historyTempChartData = useMemo(() => ({
|
||||
labels: resampledHistoryPoints.map((point) => String(point?.capturedAt || '').trim()),
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU',
|
||||
data: historyPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.cpuTemperatureC)),
|
||||
borderColor: CPU_SERIES_COLOR,
|
||||
backgroundColor: CPU_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
@@ -389,7 +592,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
},
|
||||
{
|
||||
label: 'GPU',
|
||||
data: historyPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
|
||||
data: resampledHistoryPoints.map((point) => toNumberOrNull(point?.gpuTemperatureC)),
|
||||
borderColor: GPU_SERIES_COLOR,
|
||||
backgroundColor: GPU_SERIES_COLOR,
|
||||
borderWidth: 2,
|
||||
@@ -398,7 +601,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
tension: 0.22
|
||||
}
|
||||
]
|
||||
}), [historyPoints, tempSeriesVisible]);
|
||||
}), [resampledHistoryPoints, tempSeriesVisible]);
|
||||
|
||||
return (
|
||||
<div className="hardware-detail-page">
|
||||
@@ -463,7 +666,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
{historyState.error ? <small className="error-text">{historyState.error}</small> : null}
|
||||
{historyState.loading && historyPoints.length === 0 ? (
|
||||
<p>Lade Verlauf ...</p>
|
||||
) : historyPoints.length === 0 ? (
|
||||
) : resampledHistoryPoints.length === 0 ? (
|
||||
<p>Noch keine Verlaufsdaten vorhanden.</p>
|
||||
) : (
|
||||
<div className="hardware-history-grid">
|
||||
@@ -502,6 +705,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
type="line"
|
||||
data={historyUsageChartData}
|
||||
options={historyChartOptions}
|
||||
plugins={[historyDayBoundaryPlugin]}
|
||||
/>
|
||||
</div>
|
||||
<ChartLegendRow
|
||||
@@ -539,6 +743,7 @@ export default function HardwarePage({ hardwareMonitoring }) {
|
||||
type="line"
|
||||
data={historyTempChartData}
|
||||
options={historyChartOptions}
|
||||
plugins={[historyDayBoundaryPlugin]}
|
||||
/>
|
||||
</div>
|
||||
<ChartLegendRow
|
||||
|
||||
@@ -1476,22 +1476,27 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
||||
: null;
|
||||
const liveState = String(liveJobProgress?.state || '').trim().toUpperCase();
|
||||
const isTerminalJobStatus = jobStatus === 'FINISHED' || jobStatus === 'ERROR' || jobStatus === 'CANCELLED';
|
||||
const ignoreStaleLiveProgress = isTerminalJobStatus && processingStates.includes(liveState);
|
||||
const mergedContext = liveContext
|
||||
const jobStatusIsRunning = processingStates.includes(jobStatus);
|
||||
const ignoreStaleLiveProgress = (
|
||||
(isTerminalJobStatus && processingStates.includes(liveState))
|
||||
|| (!jobStatusIsRunning && jobStatus !== 'IDLE' && processingStates.includes(liveState))
|
||||
);
|
||||
const effectiveLiveContext = ignoreStaleLiveProgress ? null : liveContext;
|
||||
const mergedContext = effectiveLiveContext
|
||||
? {
|
||||
...computedContext,
|
||||
...liveContext,
|
||||
...effectiveLiveContext,
|
||||
// Prefer the DB plan when it has been confirmed but the live (jobProgress) plan hasn't —
|
||||
// this happens when confirmEncodeReview is called with skipPipelineStateUpdate=true
|
||||
// (always the case for queued jobs), leaving jobProgress with the pre-confirmation plan.
|
||||
mediaInfoReview: (computedContext.mediaInfoReview?.reviewConfirmedAt && !liveContext.mediaInfoReview?.reviewConfirmedAt)
|
||||
mediaInfoReview: (computedContext.mediaInfoReview?.reviewConfirmedAt && !effectiveLiveContext.mediaInfoReview?.reviewConfirmedAt)
|
||||
? computedContext.mediaInfoReview
|
||||
: (liveContext.mediaInfoReview || computedContext.mediaInfoReview),
|
||||
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
|
||||
? liveContext.tracks
|
||||
: (effectiveLiveContext.mediaInfoReview || computedContext.mediaInfoReview),
|
||||
tracks: (Array.isArray(effectiveLiveContext.tracks) && effectiveLiveContext.tracks.length > 0)
|
||||
? effectiveLiveContext.tracks
|
||||
: computedContext.tracks,
|
||||
selectedMetadata: computedContext.selectedMetadata || liveContext.selectedMetadata,
|
||||
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
|
||||
selectedMetadata: computedContext.selectedMetadata || effectiveLiveContext.selectedMetadata,
|
||||
cdRipConfig: effectiveLiveContext.cdRipConfig || computedContext.cdRipConfig
|
||||
}
|
||||
: computedContext;
|
||||
// The card always represents `job.id`; never let stale live context override it.
|
||||
|
||||
Reference in New Issue
Block a user