457 lines
15 KiB
Svelte
457 lines
15 KiB
Svelte
<script lang="ts">
|
|
import type { Snippet } from 'svelte';
|
|
import type { CapacityHistory, TemperatureHistorySeries } from '$lib/capacity.js';
|
|
import { formatTemperature } from '$lib/host-temperatures.js';
|
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
|
|
|
let {
|
|
history,
|
|
scope,
|
|
controls
|
|
}: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
|
|
let visibility = $state<Record<string, boolean>>({});
|
|
let selectedAt = $state<number | null>(null);
|
|
const series = $derived(history.temperatures);
|
|
const visible = $derived(series.filter((s) => visibility[s.id] ?? s.kind === 'cpu'));
|
|
const values = $derived(
|
|
series.flatMap((s) => s.points.flatMap((p) => (p.celsius === null ? [] : [p.celsius])))
|
|
);
|
|
const minimum = $derived(
|
|
values.length
|
|
? Math.floor((values.reduce((a, b) => Math.min(a, b), Infinity) - 5) / 10) * 10
|
|
: 0
|
|
);
|
|
const maximum = $derived(
|
|
values.length
|
|
? Math.ceil((values.reduce((a, b) => Math.max(a, b), -Infinity) + 5) / 10) * 10
|
|
: 100
|
|
);
|
|
const times = $derived(history.points.map((p) => p.at));
|
|
const measured = $derived(visible.some((s) => s.points.some((p) => p.celsius !== null)));
|
|
const selectedIndex = $derived.by(() => {
|
|
if (selectedAt !== null)
|
|
return times.reduce(
|
|
(best, at, i) =>
|
|
Math.abs(at - selectedAt!) < Math.abs(times[best] - selectedAt!) ? i : best,
|
|
0
|
|
);
|
|
for (let i = times.length - 1; i >= 0; i--)
|
|
if (visible.some((s) => s.points[i]?.celsius != null)) return i;
|
|
return Math.max(0, times.length - 1);
|
|
});
|
|
const selectedTime = $derived(times[selectedIndex]);
|
|
const readout = $derived(
|
|
visible
|
|
.map((s) => `${s.label}: ${formatTemperature(s.points[selectedIndex]?.celsius)}`)
|
|
.join('. ')
|
|
);
|
|
const ticks = [0, 0.25, 0.5, 0.75, 1];
|
|
function color(s: TemperatureHistorySeries) {
|
|
return `var(--chart-series-${(series.indexOf(s) % 6) + 1})`;
|
|
}
|
|
function dash(s: TemperatureHistorySeries) {
|
|
return ['', '7 4', '2 4'][Math.floor(series.indexOf(s) / 6) % 3];
|
|
}
|
|
function x(at: number) {
|
|
return (
|
|
8 +
|
|
984 *
|
|
Math.max(
|
|
0,
|
|
Math.min(
|
|
1,
|
|
(at - history.startAt) / Math.max(1, history.endAt - history.startAt)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
function y(celsius: number) {
|
|
return 230 - (220 * (celsius - minimum)) / (maximum - minimum);
|
|
}
|
|
function segments(s: TemperatureHistorySeries) {
|
|
const result: { x: number; y: number }[][] = [];
|
|
let segment: { x: number; y: number }[] = [];
|
|
for (const point of s.points) {
|
|
if (point.celsius === null) {
|
|
if (segment.length) result.push(segment);
|
|
segment = [];
|
|
} else segment.push({ x: x(point.at), y: y(point.celsius) });
|
|
}
|
|
if (segment.length) result.push(segment);
|
|
return result;
|
|
}
|
|
function inspect(event: PointerEvent) {
|
|
if (!times.length || (event.type === 'pointermove' && event.pointerType === 'touch'))
|
|
return;
|
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
selectedAt =
|
|
history.startAt +
|
|
Math.max(
|
|
0,
|
|
Math.min(1, (((event.clientX - rect.left) / rect.width) * 1000 - 8) / 984)
|
|
) *
|
|
(history.endAt - history.startAt);
|
|
}
|
|
function move(event: KeyboardEvent) {
|
|
if (!times.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
|
|
return;
|
|
event.preventDefault();
|
|
const next =
|
|
event.key === 'Home'
|
|
? 0
|
|
: event.key === 'End'
|
|
? times.length - 1
|
|
: Math.max(
|
|
0,
|
|
Math.min(
|
|
times.length - 1,
|
|
selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)
|
|
)
|
|
);
|
|
selectedAt = times[next];
|
|
}
|
|
</script>
|
|
|
|
<section class="panel" id="temperatures" aria-labelledby="temperature-history-title">
|
|
<div class="heading">
|
|
<div>
|
|
<h2 id="temperature-history-title">Temperature history</h2>
|
|
<p>{scope} · Peak temperature per period</p>
|
|
</div>
|
|
{@render controls?.()}
|
|
</div>
|
|
<div class="legend" role="group" aria-label="Temperature series">
|
|
{#each series as sensor (sensor.id)}
|
|
<button
|
|
type="button"
|
|
aria-pressed={visibility[sensor.id] ?? sensor.kind === 'cpu'}
|
|
onclick={() =>
|
|
(visibility[sensor.id] = !(visibility[sensor.id] ?? sensor.kind === 'cpu'))}
|
|
>
|
|
<svg viewBox="0 0 24 8" aria-hidden="true"
|
|
><line
|
|
x1="0"
|
|
x2="24"
|
|
y1="4"
|
|
y2="4"
|
|
stroke={color(sensor)}
|
|
stroke-dasharray={dash(sensor)}
|
|
/></svg
|
|
>
|
|
{sensor.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<div class="axis-title">Temperature (°C)</div>
|
|
<div class="chart-grid">
|
|
<div class="y-axis" aria-hidden="true">
|
|
{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}
|
|
>{minimum + (maximum - minimum) * tick}</span
|
|
>{/each}
|
|
</div>
|
|
<div
|
|
class="plot"
|
|
role="slider"
|
|
tabindex="0"
|
|
aria-label="Temperature history. Use left and right arrows to inspect a period."
|
|
aria-valuemin="0"
|
|
aria-valuemax={Math.max(0, times.length - 1)}
|
|
aria-valuenow={selectedIndex}
|
|
aria-valuetext={selectedTime === undefined
|
|
? 'No temperature history'
|
|
: `${$timeFormat.period(selectedTime)}. ${readout || 'No sensors selected'}`}
|
|
onpointermove={inspect}
|
|
onpointerdown={inspect}
|
|
onkeydown={move}
|
|
>
|
|
<svg viewBox="0 0 1000 240" preserveAspectRatio="none" aria-hidden="true">
|
|
{#each ticks as tick}<line
|
|
class="grid-line"
|
|
x1="0"
|
|
x2="1000"
|
|
y1={y(minimum + (maximum - minimum) * tick)}
|
|
y2={y(minimum + (maximum - minimum) * tick)}
|
|
/>{/each}
|
|
{#each visible as sensor (sensor.id)}
|
|
<g data-series={sensor.id}>
|
|
{#each segments(sensor) as segment}
|
|
<path
|
|
class="trend"
|
|
stroke={color(sensor)}
|
|
stroke-dasharray={dash(sensor)}
|
|
d={segment
|
|
.map((point, i) => `${i ? 'L' : 'M'}${point.x},${point.y}`)
|
|
.join(' ')}
|
|
/>
|
|
{#if segment.length === 1}<circle
|
|
cx={segment[0].x}
|
|
cy={segment[0].y}
|
|
r="3"
|
|
fill={color(sensor)}
|
|
/>{/if}
|
|
{/each}
|
|
{#if sensor.points[selectedIndex]?.celsius != null}<circle
|
|
class="selected-point"
|
|
cx={x(selectedTime)}
|
|
cy={y(sensor.points[selectedIndex].celsius!)}
|
|
r="4"
|
|
fill={color(sensor)}
|
|
/>{/if}
|
|
</g>
|
|
{/each}
|
|
{#if selectedTime !== undefined && measured}<line
|
|
class="cursor"
|
|
x1={x(selectedTime)}
|
|
x2={x(selectedTime)}
|
|
y1="5"
|
|
y2="235"
|
|
/>{/if}
|
|
</svg>
|
|
{#if !measured}<div class="empty-chart">
|
|
<strong
|
|
>{visible.length || !series.length
|
|
? 'No temperature readings in this period'
|
|
: 'Select a sensor to show history'}</strong
|
|
>
|
|
{#if visible.length || !series.length}<span
|
|
>Readings appear when an agent reports supported temperature sensors.</span
|
|
>{/if}
|
|
</div>{/if}
|
|
</div>
|
|
<div class="x-axis" aria-hidden="true">
|
|
<span>{$timeFormat.period(history.startAt)}</span><span
|
|
>{$timeFormat.period(history.endAt)}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
{#if selectedTime !== undefined && visible.length}
|
|
<div class="readout" aria-label="Selected temperature period">
|
|
<span>{$timeFormat.period(selectedTime)}</span>
|
|
<dl>
|
|
{#each visible as sensor (sensor.id)}<div>
|
|
<dt>{sensor.label}</dt>
|
|
<dd>{formatTemperature(sensor.points[selectedIndex]?.celsius)}</dd>
|
|
</div>{/each}
|
|
</dl>
|
|
</div>
|
|
{/if}
|
|
<details>
|
|
<summary>About this chart</summary>
|
|
<p>
|
|
Each CPU line represents one host: its hottest reported package or physical die, falling
|
|
back to its hottest core. Select a host to compare its CPU, NVMe drives and chipset.
|
|
Per-minute peaks are retained; longer periods show their highest reading. Gaps mean no
|
|
measurement was received in that period. Temperatures include all workloads and are
|
|
unaffected by the game filter. Colors identify series; they do not indicate safe or
|
|
unsafe temperatures.
|
|
</p>
|
|
</details>
|
|
</section>
|
|
|
|
<style>
|
|
.panel {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--space-5);
|
|
min-width: 0;
|
|
}
|
|
h2 {
|
|
margin: 0;
|
|
font-size: var(--text-lg);
|
|
}
|
|
.heading {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: var(--space-4);
|
|
flex-wrap: wrap;
|
|
}
|
|
.heading p {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-sm);
|
|
margin: var(--space-2) 0 0;
|
|
}
|
|
.legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-2) var(--space-5);
|
|
margin: var(--space-4) 0;
|
|
}
|
|
.legend button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
background: none;
|
|
border: 0;
|
|
padding: var(--space-2) 0;
|
|
color: var(--text-primary);
|
|
font: inherit;
|
|
font-size: var(--text-sm);
|
|
cursor: pointer;
|
|
overflow-wrap: anywhere;
|
|
max-width: 100%;
|
|
}
|
|
.legend button[aria-pressed='false'] {
|
|
color: var(--text-secondary);
|
|
}
|
|
.legend button[aria-pressed='false'] svg {
|
|
opacity: 0.35;
|
|
}
|
|
.legend svg {
|
|
width: 24px;
|
|
height: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
.legend line {
|
|
stroke-width: 3;
|
|
}
|
|
button:focus-visible,
|
|
.plot:focus-visible,
|
|
summary:focus-visible {
|
|
outline: 2px solid var(--accent-primary);
|
|
outline-offset: 3px;
|
|
}
|
|
.axis-title,
|
|
.x-axis,
|
|
.y-axis {
|
|
color: var(--text-tertiary);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.chart-grid {
|
|
display: grid;
|
|
grid-template-columns: 42px minmax(0, 1fr);
|
|
gap: 10px;
|
|
margin-top: 14px;
|
|
}
|
|
.y-axis {
|
|
position: relative;
|
|
height: 220px;
|
|
margin-top: 10px;
|
|
}
|
|
.y-axis span {
|
|
position: absolute;
|
|
right: 0;
|
|
transform: translateY(-50%);
|
|
}
|
|
.plot {
|
|
position: relative;
|
|
height: 240px;
|
|
cursor: crosshair;
|
|
touch-action: pan-y;
|
|
}
|
|
.plot > svg {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: visible;
|
|
}
|
|
.plot path,
|
|
.plot line {
|
|
vector-effect: non-scaling-stroke;
|
|
}
|
|
.grid-line {
|
|
stroke: var(--border-subtle);
|
|
stroke-width: 1;
|
|
}
|
|
.trend {
|
|
fill: none;
|
|
stroke-width: 2;
|
|
stroke-linejoin: round;
|
|
stroke-linecap: round;
|
|
}
|
|
.selected-point {
|
|
stroke: var(--surface);
|
|
stroke-width: 2;
|
|
}
|
|
.cursor {
|
|
stroke: var(--text-tertiary);
|
|
stroke-width: 1;
|
|
stroke-dasharray: 4 5;
|
|
}
|
|
.x-axis {
|
|
grid-column: 2;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: var(--space-4);
|
|
}
|
|
.x-axis span:last-child {
|
|
text-align: right;
|
|
}
|
|
.empty-chart {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
padding: var(--space-4);
|
|
text-align: center;
|
|
pointer-events: none;
|
|
}
|
|
.empty-chart strong {
|
|
font-size: var(--text-sm);
|
|
font-weight: var(--weight-medium);
|
|
}
|
|
.empty-chart span {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-secondary);
|
|
}
|
|
.readout {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-4);
|
|
font-size: var(--text-xs);
|
|
margin: var(--space-4) 0;
|
|
}
|
|
.readout > span,
|
|
dt {
|
|
color: var(--text-secondary);
|
|
}
|
|
dl {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-3) var(--space-5);
|
|
margin: 0;
|
|
}
|
|
dl > div {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
min-width: 0;
|
|
gap: var(--space-2);
|
|
}
|
|
dt {
|
|
overflow-wrap: anywhere;
|
|
}
|
|
dd {
|
|
margin: 0;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
summary {
|
|
cursor: pointer;
|
|
font-size: var(--text-xs);
|
|
color: var(--text-secondary);
|
|
padding: var(--space-2) 0;
|
|
}
|
|
details p {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
line-height: 1.65;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.panel {
|
|
padding: var(--space-4);
|
|
}
|
|
.chart-grid {
|
|
grid-template-columns: 32px minmax(0, 1fr);
|
|
gap: 8px;
|
|
}
|
|
}
|
|
@media (pointer: coarse) {
|
|
.legend button {
|
|
min-height: 44px;
|
|
}
|
|
}
|
|
</style>
|