1.0.0-rc3 rc3

This commit is contained in:
2026-05-27 09:49:12 +02:00
parent eeef1fdb73
commit 2359c03bc8
13 changed files with 411 additions and 76 deletions
+218 -13
View File
@@ -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
+14 -9
View File
@@ -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.