Merge branch 'master' into 166_add_scopes_analysis

This commit is contained in:
Christoph Kluge
2023-08-22 15:26:20 +02:00
63 changed files with 3864 additions and 2601 deletions

View File

@@ -1,11 +1,13 @@
<script>
import { init } from './utils.js'
import { init, convert2uplot } from './utils.js'
import { getContext, onMount } from 'svelte'
import { queryStore, gql, getContextClient } from '@urql/svelte'
import { Row, Col, Spinner, Card, Table } from 'sveltestrap'
import { Row, Col, Spinner, Card, Table, Icon } from 'sveltestrap'
import Filters from './filters/Filters.svelte'
import PlotSelection from './PlotSelection.svelte'
import Histogram, { binsFromFootprint } from './plots/Histogram.svelte'
import Histogram from './plots/Histogram.svelte'
import Pie, { colors } from './plots/Pie.svelte'
import { binsFromFootprint } from './utils.js'
import ScatterPlot from './plots/Scatter.svelte'
import PlotTable from './PlotTable.svelte'
import Roofline from './plots/Roofline.svelte'
@@ -29,7 +31,7 @@
let filterComponent; // see why here: https://stackoverflow.com/questions/58287729/how-can-i-export-a-function-from-a-svelte-component-that-changes-a-value-in-the
let jobFilters = [];
let rooflineMaxY;
let colWidth;
let colWidth1, colWidth2, colWidth3, colWidth4;
let numBins = 50;
let maxY = -1;
const ccconfig = getContext('cc-config')
@@ -134,78 +136,104 @@
</Col>
</Row>
{:else if $statsQuery.data}
<Row>
<div class="col-3" bind:clientWidth={colWidth}>
<div style="height: 40%">
<Table>
<tr>
<th scope="col">Total Jobs</th>
<td>{$statsQuery.data.stats[0].totalJobs}</td>
</tr>
<tr>
<th scope="col">Short Jobs</th>
<td>{$statsQuery.data.stats[0].shortJobs}</td>
</tr>
<tr>
<th scope="col">Total Walltime</th>
<td>{$statsQuery.data.stats[0].totalWalltime}</td>
</tr>
<tr>
<th scope="col">Total Core Hours</th>
<td>{$statsQuery.data.stats[0].totalCoreHours}</td>
</tr>
</Table>
</div>
<div style="height: 60%;">
{#key $statsQuery.data.topUsers}
<h4>Top Users (by node hours)</h4>
<Histogram
width={colWidth - 25} height={300 * 0.5} small={true}
data={$statsQuery.data.topUsers.sort((a, b) => b.count - a.count).map(({ count }, idx) => ({ count, value: idx }))}
label={(x) => x < $statsQuery.data.topUsers.length ? $statsQuery.data.topUsers[Math.floor(x)].name : 'No Users'}
ylabel="Node Hours [h]"/>
{/key}
</div>
</div>
<div class="col-3">
{#key $statsQuery.data.stats[0].histDuration}
<h4>Duration Distribution</h4>
<Histogram
width={colWidth - 25}
data={$statsQuery.data.stats[0].histDuration}
xlabel="Current Runtimes [h]"
ylabel="Number of Jobs"/>
<Row cols={3} class="mb-4">
<Col>
<Table>
<tr>
<th scope="col">Total Jobs</th>
<td>{$statsQuery.data.stats[0].totalJobs}</td>
</tr>
<tr>
<th scope="col">Short Jobs</th>
<td>{$statsQuery.data.stats[0].shortJobs}</td>
</tr>
<tr>
<th scope="col">Total Walltime</th>
<td>{$statsQuery.data.stats[0].totalWalltime}</td>
</tr>
<tr>
<th scope="col">Total Core Hours</th>
<td>{$statsQuery.data.stats[0].totalCoreHours}</td>
</tr>
</Table>
</Col>
<Col>
<div bind:clientWidth={colWidth1}>
<h5>Top Users</h5>
{#key $statsQuery.data.topUsers}
<Pie
size={colWidth1}
sliceLabel='Hours'
quantities={$statsQuery.data.topUsers.sort((a, b) => b.count - a.count).map((tu) => tu.count)}
entities={$statsQuery.data.topUsers.sort((a, b) => b.count - a.count).map((tu) => tu.name)}
/>
{/key}
</div>
<div class="col-3">
{#key $statsQuery.data.stats[0].histNumNodes}
<h4>Number of Nodes Distribution</h4>
<Histogram
width={colWidth - 25}
data={$statsQuery.data.stats[0].histNumNodes}
xlabel="Allocated Nodes [#]"
ylabel="Number of Jobs" />
{/key}
</div>
<div class="col-3">
</div>
</Col>
<Col>
<Table>
<tr class="mb-2"><th>Legend</th><th>User Name</th><th>Node Hours</th></tr>
{#each $statsQuery.data.topUsers.sort((a, b) => b.count - a.count) as { name, count }, i}
<tr>
<td><Icon name="circle-fill" style="color: {colors[i]};"/></td>
<th scope="col"><a href="/monitoring/user/{name}?cluster={cluster.name}">{name}</a></th>
<td>{count}</td>
</tr>
{/each}
</Table>
</Col>
</Row>
<Row cols={3} class="mb-2">
<Col>
{#if $rooflineQuery.fetching}
<Spinner />
{:else if $rooflineQuery.error}
<Card body color="danger">{$rooflineQuery.error.message}</Card>
{:else if $rooflineQuery.data && cluster}
<div bind:clientWidth={colWidth2}>
{#key $rooflineQuery.data}
<Roofline
width={colWidth - 25}
width={colWidth2} height={300}
tiles={$rooflineQuery.data.rooflineHeatmap}
cluster={cluster.subClusters.length == 1 ? cluster.subClusters[0] : null}
maxY={rooflineMaxY} />
{/key}
</div>
{/if}
</div>
</Col>
<Col>
<div bind:clientWidth={colWidth3}>
{#key $statsQuery.data.stats[0].histDuration}
<Histogram
width={colWidth3} height={300}
data={convert2uplot($statsQuery.data.stats[0].histDuration)}
title="Duration Distribution"
xlabel="Current Runtimes"
xunit="Hours"
ylabel="Number of Jobs"
yunit="Jobs"/>
{/key}
</div>
</Col>
<Col>
<div bind:clientWidth={colWidth4}>
{#key $statsQuery.data.stats[0].histNumNodes}
<Histogram
width={colWidth4} height={300}
data={convert2uplot($statsQuery.data.stats[0].histNumNodes)}
title="Number of Nodes Distribution"
xlabel="Allocated Nodes"
xunit="Nodes"
ylabel="Number of Jobs"
yunit="Jobs"/>
{/key}
</div>
</Col>
</Row>
{/if}
<br/>
<hr class="my-6"/>
{#if $footprintsQuery.error}
<Row>
<Col>
@@ -233,15 +261,16 @@
$footprintsQuery.data.footprints.metrics.find(f => f.metric == metric).data, numBins) }))}
itemsPerRow={ccconfig.plot_view_plotsPerRow}>
<h4>Average Distribution of '{item.metric}'</h4>
<Histogram
data={convert2uplot(item.bins)}
width={width} height={250}
min={item.min} max={item.max}
data={item.bins}
label={item.label}
xlabel={`${item.metric} Average [${(metricConfig(cluster.name, item.metric)?.unit?.prefix ? metricConfig(cluster.name, item.metric)?.unit?.prefix : '') +
title="Average Distribution of '{item.metric}'"
xlabel={`${item.metric} average [${(metricConfig(cluster.name, item.metric)?.unit?.prefix ? metricConfig(cluster.name, item.metric)?.unit?.prefix : '') +
(metricConfig(cluster.name, item.metric)?.unit?.base ? metricConfig(cluster.name, item.metric)?.unit?.base : '')}]`}
ylabel="Node Hours [h]" />
xunit={`${(metricConfig(cluster.name, item.metric)?.unit?.prefix ? metricConfig(cluster.name, item.metric)?.unit?.prefix : '') +
(metricConfig(cluster.name, item.metric)?.unit?.base ? metricConfig(cluster.name, item.metric)?.unit?.base : '')}`}
ylabel="Node Hours"
yunit="Hours"/>
</PlotTable>
</Col>
</Row>
@@ -260,6 +289,7 @@
<PlotTable
let:item
let:width
renderFor="analysis"
items={metricsInScatterplots.map(([m1, m2]) => ({
m1, f1: $footprintsQuery.data.footprints.metrics.find(f => f.metric == m1).data,
m2, f2: $footprintsQuery.data.footprints.metrics.find(f => f.metric == m2).data }))}
@@ -278,7 +308,7 @@
{/if}
<style>
h4 {
h5 {
text-align: center;
}
</style>

View File

@@ -93,7 +93,7 @@
<InputGroup>
<Input type="text" placeholder="Search 'type:<query>' ..." name="searchId"/>
<Button outline type="submit"><Icon name="search"/></Button>
<InputGroupText style="cursor:help;" title={(authlevel >= roles.support) ? "Example: 'projectId:a100cd', Types are: jobId | jobName | projectId | username | name" : "Example: 'jobName:myjob', Types are jobId | jobName | projectId"}><Icon name="info-circle"/></InputGroupText>
<InputGroupText style="cursor:help;" title={(authlevel >= roles.support) ? "Example: 'projectId:a100cd', Types are: jobId | jobName | projectId | arrayJobId | username | name" : "Example: 'jobName:myjob', Types are jobId | jobName | projectId | arrayJobId "}><Icon name="info-circle"/></InputGroupText>
</InputGroup>
</form>
{#if username}

View File

@@ -20,12 +20,11 @@
} from "sveltestrap";
import PlotTable from "./PlotTable.svelte";
import Metric from "./Metric.svelte";
import PolarPlot from "./plots/Polar.svelte";
import Polar from "./plots/Polar.svelte";
import Roofline from "./plots/Roofline.svelte";
import JobInfo from "./joblist/JobInfo.svelte";
import TagManagement from "./TagManagement.svelte";
import MetricSelection from "./MetricSelection.svelte";
import Zoom from "./Zoom.svelte";
import StatsTable from "./StatsTable.svelte";
import { getContext } from "svelte";
@@ -33,6 +32,9 @@
export let authlevel;
export let roles;
const accMetrics = ['acc_utilization', 'acc_mem_used', 'acc_power', 'nv_mem_util', 'nv_sm_clock', 'nv_temp'];
let accNodeOnly
const { query: initq } = init(`
job(id: "${dbid}") {
id, jobId, user, project, cluster, startTime,
@@ -48,7 +50,8 @@
`);
const ccconfig = getContext("cc-config"),
clusters = getContext("clusters");
clusters = getContext("clusters"),
metrics = getContext("metrics")
let isMetricsSelectionOpen = false,
selectedMetrics = [],
@@ -74,16 +77,25 @@
ccconfig[`job_view_nodestats_selectedMetrics`]),
]);
// Select default Scopes to load
if (job.numAcc === 0) {
// No Accels
// Select default Scopes to load: Check before if accelerator metrics are not on accelerator scope by default
accNodeOnly = [...toFetch].some(function(m) {
if (accMetrics.includes(m)) {
const mc = metrics(job.cluster, m)
return mc.scope !== 'accelerator'
} else {
return false
}
})
if (job.numAcc === 0 || accNodeOnly === true) {
// No Accels or Accels on Node Scope
startFetching(
job,
[...toFetch],
job.numNodes > 2 ? ["node"] : ["node", "core"]
);
} else {
// Accels
// Accels and not on node scope
startFetching(
job,
[...toFetch],
@@ -121,7 +133,6 @@
jobTags,
fullWidth,
statsTable;
$: polarPlotSize = Math.min(fullWidth / 3 - 10, 300);
$: document.title = $initq.fetching
? "Loading..."
: $initq.error
@@ -233,9 +244,8 @@
{/if}
{/if}
<Col>
<PolarPlot
width={polarPlotSize}
height={polarPlotSize}
<Polar
size={fullWidth / 4.1}
metrics={ccconfig[
`job_view_polarPlotMetrics:${$initq.data.job.cluster}`
] || ccconfig[`job_view_polarPlotMetrics`]}
@@ -246,7 +256,7 @@
<Col>
<Roofline
width={fullWidth / 3 - 10}
height={polarPlotSize}
height={fullWidth / 5}
cluster={clusters
.find((c) => c.name == $initq.data.job.cluster)
.subClusters.find(
@@ -279,9 +289,9 @@
</Button>
{/if}
</Col>
<Col xs="auto">
<!-- <Col xs="auto">
<Zoom timeseriesPlots={plots} />
</Col>
</Col> -->
</Row>
<br />
<Row>
@@ -318,6 +328,7 @@
scopes={item.data.map((x) => x.scope)}
{width}
isShared={$initq.data.job.exclusive != 1}
resources={$initq.data.job.resources}
/>
{:else}
<Card body color="warning"
@@ -385,6 +396,8 @@
bind:this={statsTable}
job={$initq.data.job}
jobMetrics={$jobMetrics.data.jobMetrics}
accMetrics={accMetrics}
accNodeOnly={accNodeOnly}
/>
{/key}
{/if}

View File

@@ -119,10 +119,10 @@
<thead>
<tr>
<th scope="col">
<!-- {({ -->
<!-- USER: "Username", -->
<!-- PROJECT: "Project Name", -->
<!-- })[type]} -->
{({
USER: "Username",
PROJECT: "Project Name",
})[type]}
<Button
color={sorting.field == "id" ? "primary" : "light"}
size="sm"
@@ -216,14 +216,14 @@
>
{:else if type == "PROJECT"}
<a href="/monitoring/jobs/?project={row.id}"
>{row.id}</a
>{scrambleNames ? scramble(row.id) : row.id}</a
>
{:else}
{row.id}
{/if}
</td>
{#if type == "USER"}
<td>{row?.name ? row.name : ""}</td>
<td>{scrambleNames ? scramble(row?.name?row.name:"-") : row?.name?row.name:"-"}</td>
{/if}
<td>{row.totalJobs}</td>
<td>{row.totalWalltime}</td>

View File

@@ -89,6 +89,7 @@
timestep={data.timestep}
scope={selectedScope} metric={metricName}
series={series}
isShared={isShared} />
isShared={isShared}
resources={job.resources} />
{/if}
{/key}

View File

@@ -11,6 +11,7 @@
} from "sveltestrap";
import { queryStore, gql, getContextClient } from "@urql/svelte";
import TimeSelection from "./filters/TimeSelection.svelte";
import Refresher from './joblist/Refresher.svelte';
import PlotTable from "./PlotTable.svelte";
import MetricPlot from "./plots/MetricPlot.svelte";
import { getContext } from "svelte";
@@ -160,6 +161,13 @@
No currently running jobs.
{/if}
</Col>
<Col>
<Refresher on:reload={() => {
const diff = Date.now() - to
from = new Date(from.getTime() + diff)
to = new Date(to.getTime() + diff)
}} />
</Col>
<Col>
<TimeSelection bind:from bind:to />
</Col>
@@ -203,6 +211,7 @@
subCluster={$nodeMetricsData.data.nodeMetrics[0]
.subCluster}
series={item.metric.series}
resources={[{hostname: hostname}]}
/>
{:else if item.disabled === true && item.metric}
<Card

View File

@@ -7,6 +7,8 @@
export let job
export let jobMetrics
export let accMetrics
export let accNodeOnly
const allMetrics = [...new Set(jobMetrics.map(m => m.name))].sort(),
scopesForMetric = (metric) => jobMetrics
@@ -19,9 +21,19 @@
isMetricSelectionOpen = false,
selectedMetrics = getContext('cc-config')[`job_view_nodestats_selectedMetrics:${job.cluster}`]
|| getContext('cc-config')['job_view_nodestats_selectedMetrics']
for (let metric of allMetrics) {
selectedScopes[metric] = maxScope(scopesForMetric(metric))
// Not Exclusive or Single Node: Get maxScope()
// No Accelerators in Job and not Acc-Metric: Use 'core'
// Accelerator Metric available on accelerator scope: Use 'accelerator'
// Accelerator Metric only on node scope: Fallback to 'node'
selectedScopes[metric] = (job.exclusive != 1 || job.numNodes == 1) ?
(job.numAccs != 0 && accMetrics.includes(metric)) ?
accNodeOnly ?
'node'
: 'accelerator'
: 'core'
: maxScope(scopesForMetric(metric))
sorting[metric] = {
min: { dir: 'up', active: false },
avg: { dir: 'up', active: false },

View File

@@ -1,12 +1,49 @@
<script>
import { Icon } from 'sveltestrap'
export let host
export let metric
export let scope
export let jobMetrics
function compareNumbers(a, b) {
return a.id - b.id;
}
function sortByField(field) {
let s = sorting[field]
if (s.active) {
s.dir = s.dir == 'up' ? 'down' : 'up'
} else {
for (let field in sorting)
sorting[field].active = false
s.active = true
}
sorting = {...sorting}
series = series.sort((a, b) => {
if (a == null || b == null)
return -1
if (field === 'id') {
return s.dir != 'up' ? a[field] - b[field] : b[field] - a[field]
} else {
return s.dir != 'up' ? a.statistics[field] - b.statistics[field] : b.statistics[field] - a.statistics[field]
}
})
}
let sorting = {
id: { dir: 'down', active: true },
min: { dir: 'up', active: false },
avg: { dir: 'up', active: false },
max: { dir: 'up', active: false },
}
$: series = jobMetrics
.find(jm => jm.name == metric && jm.scope == scope)
?.metric.series.filter(s => s.hostname == host && s.statistics != null)
?.sort(compareNumbers)
</script>
{#if series == null || series.length == 0}
@@ -24,6 +61,14 @@
{:else}
<td colspan="4">
<table style="width: 100%;">
<tr>
{#each ['id', 'min', 'avg', 'max'] as field}
<th on:click={() => sortByField(field)}>
Sort
<Icon name="caret-{sorting[field].dir}{sorting[field].active ? '-fill' : ''}" />
</th>
{/each}
</tr>
{#each series as s, i}
<tr>
<th>{s.id ?? i}</th>

View File

@@ -1,9 +1,10 @@
<script>
import Refresher from './joblist/Refresher.svelte'
import Roofline, { transformPerNodeData } from './plots/Roofline.svelte'
import Pie, { colors } from './plots/Pie.svelte'
import Histogram from './plots/Histogram.svelte'
import { Row, Col, Spinner, Card, CardHeader, CardTitle, CardBody, Table, Progress, Icon } from 'sveltestrap'
import { init } from './utils.js'
import { init, convert2uplot } from './utils.js'
import { scaleNumbers } from './units.js'
import { queryStore, gql, getContextClient } from '@urql/svelte'
@@ -159,21 +160,24 @@
<Row cols={4}>
<Col class="p-2">
<div bind:clientWidth={colWidth1}>
<h4 class="mb-3 text-center">Top Users</h4>
<h4 class="text-center">Top Users</h4>
{#key $mainQuery.data}
<Histogram
width={colWidth1 - 25}
data={$mainQuery.data.topUsers.sort((a, b) => b.count - a.count).map(({ count }, idx) => ({ count, value: idx }))}
label={(x) => x < $mainQuery.data.topUsers.length ? $mainQuery.data.topUsers[Math.floor(x)].name : '0'}
xlabel="User Name" ylabel="Number of Jobs" />
<Pie
size={colWidth1}
sliceLabel='Jobs'
quantities={$mainQuery.data.topUsers.sort((a, b) => b.count - a.count).map((tu) => tu.count)}
entities={$mainQuery.data.topUsers.sort((a, b) => b.count - a.count).map((tu) => tu.name)}
/>
{/key}
</div>
</Col>
<Col class="px-4 py-2">
<Table>
<tr class="mb-2"><th>User Name</th><th>Number of Nodes</th></tr>
{#each $mainQuery.data.topUsers.sort((a, b) => b.count - a.count) as { name, count }}
<tr class="mb-2"><th>Legend</th><th>User Name</th><th>Number of Nodes</th></tr>
{#each $mainQuery.data.topUsers.sort((a, b) => b.count - a.count) as { name, count }, i}
<tr>
<td><Icon name="circle-fill" style="color: {colors[i]};"/></td>
<th scope="col"><a href="/monitoring/user/{name}?cluster={cluster}&state=running">{name}</a></th>
<td>{count}</td>
</tr>
@@ -181,20 +185,22 @@
</Table>
</Col>
<Col class="p-2">
<h4 class="mb-3 text-center">Top Projects</h4>
<h4 class="text-center">Top Projects</h4>
{#key $mainQuery.data}
<Histogram
width={colWidth1 - 25}
data={$mainQuery.data.topProjects.sort((a, b) => b.count - a.count).map(({ count }, idx) => ({ count, value: idx }))}
label={(x) => x < $mainQuery.data.topProjects.length ? $mainQuery.data.topProjects[Math.floor(x)].name : '0'}
xlabel="Project Code" ylabel="Number of Jobs" />
<Pie
size={colWidth1}
sliceLabel='Jobs'
quantities={$mainQuery.data.topProjects.sort((a, b) => b.count - a.count).map((tp) => tp.count)}
entities={$mainQuery.data.topProjects.sort((a, b) => b.count - a.count).map((tp) => tp.name)}
/>
{/key}
</Col>
<Col class="px-4 py-2">
<Table>
<tr class="mb-2"><th>Project Code</th><th>Number of Nodes</th></tr>
{#each $mainQuery.data.topProjects.sort((a, b) => b.count - a.count) as { name, count }}
<tr class="mb-2"><th>Legend</th><th>Project Code</th><th>Number of Nodes</th></tr>
{#each $mainQuery.data.topProjects.sort((a, b) => b.count - a.count) as { name, count }, i}
<tr>
<td><Icon name="circle-fill" style="color: {colors[i]};"/></td>
<th scope="col"><a href="/monitoring/jobs/?cluster={cluster}&state=running&project={name}&projectMatch=eq">{name}</a></th>
<td>{count}</td>
</tr>
@@ -202,28 +208,33 @@
</Table>
</Col>
</Row>
<Row cols={2} class="mt-3">
<hr class="my-2"/>
<Row cols={2}>
<Col class="p-2">
<div bind:clientWidth={colWidth2}>
<h4 class="mb-3 text-center">Duration Distribution</h4>
{#key $mainQuery.data.stats}
<Histogram
data={convert2uplot($mainQuery.data.stats[0].histDuration)}
width={colWidth2 - 25}
data={$mainQuery.data.stats[0].histDuration}
xlabel="Current Runtimes [h]"
ylabel="Number of Jobs" />
title="Duration Distribution"
xlabel="Current Runtimes"
xunit="Hours"
ylabel="Number of Jobs"
yunit="Jobs"/>
{/key}
</div>
</Col>
<Col class="p-2">
<h4 class="mb-3 text-center">Number of Nodes Distribution</h4>
{#key $mainQuery.data.stats}
<Histogram
data={convert2uplot($mainQuery.data.stats[0].histNumNodes)}
width={colWidth2 - 25}
data={$mainQuery.data.stats[0].histNumNodes}
xlabel="Allocated Nodes [#]"
ylabel="Number of Jobs" />
title="Number of Nodes Distribution"
xlabel="Allocated Nodes"
xunit="Nodes"
ylabel="Number of Jobs"
yunit="Jobs"/>
{/key}
</Col>
</Row>
{/if}
{/if}

View File

@@ -1,5 +1,6 @@
<script>
import { init, checkMetricDisabled } from './utils.js'
import Refresher from './joblist/Refresher.svelte'
import { Row, Col, Input, InputGroup, InputGroupText, Icon, Spinner, Card } from 'sveltestrap'
import { queryStore, gql, getContextClient } from '@urql/svelte'
import TimeSelection from './filters/TimeSelection.svelte'
@@ -78,6 +79,13 @@
{:else if $initq.fetching}
<Spinner/>
{:else}
<Col>
<Refresher on:reload={() => {
const diff = Date.now() - to
from = new Date(from.getTime() + diff)
to = new Date(to.getTime() + diff)
}} />
</Col>
<Col>
<TimeSelection
bind:from={from}
@@ -136,7 +144,8 @@
series={item.data.metric.series}
metric={item.data.name}
cluster={clusters.find(c => c.name == cluster)}
subCluster={item.subCluster} />
subCluster={item.subCluster}
resources={[{hostname: item.host}]}/>
{:else if item.disabled === true && item.data}
<Card style="margin-left: 2rem;margin-right: 2rem;" body color="info">Metric disabled for subcluster <code>{selectedMetric}:{item.subCluster}</code></Card>
{:else}

View File

@@ -1,6 +1,6 @@
<script>
import { onMount, getContext } from 'svelte'
import { init } from './utils.js'
import { init, convert2uplot } from './utils.js'
import { Table, Row, Col, Button, Icon, Card, Spinner, Input } from 'sveltestrap'
import { queryStore, gql, getContextClient } from '@urql/svelte'
import Filters from './filters/Filters.svelte'
@@ -25,13 +25,6 @@
let metrics = ccconfig.plot_list_selectedMetrics, isMetricsSelectionOpen = false
let w1, w2, histogramHeight = 250
let selectedCluster = filterPresets?.cluster ? filterPresets.cluster : null
let resize = false
/* Resize Context
* A) Each viewport change triggers histogram rerender due to variable dimensions clearing canvas if not rerendered
* B) Opening filters (and some other things) triggers small change in viewport dimensions (Fix here?)
* A+B) Histogram rerenders if filters opened, high performance impact if dataload heavy
* Solution: Default to fixed histogram dimensions, allow user to enable automatic resizing
*/
const client = getContextClient();
$: stats = queryStore({
@@ -137,47 +130,31 @@
<th scope="row">Total Core Hours</th>
<td>{$stats.data.jobsStatistics[0].totalCoreHours}</td>
</tr>
<!-- <tr>
<th scope="row">Toggle Histogram Resizing</th>
<td><Input id="c3" value={resize} type="switch" on:change={() => (resize = !resize)}/></td>
</tr> -->
</tbody>
</Table>
</Col>
<div class="col-4" style="text-align: center;" bind:clientWidth={w1}>
<b>Duration Distribution</b>
<div class="col-4 text-center" bind:clientWidth={w1}>
{#key $stats.data.jobsStatistics[0].histDuration}
{#if resize == true}
<Histogram
data={$stats.data.jobsStatistics[0].histDuration}
data={convert2uplot($stats.data.jobsStatistics[0].histDuration)}
width={w1 - 25} height={histogramHeight}
xlabel="Current Runtimes [h]"
ylabel="Number of Jobs"/>
{:else}
<Histogram
data={$stats.data.jobsStatistics[0].histDuration}
width={400} height={250}
xlabel="Current Runtimes [h]"
ylabel="Number of Jobs"/>
{/if}
title="Duration Distribution"
xlabel="Current Runtimes"
xunit="Hours"
ylabel="Number of Jobs"
yunit="Jobs"/>
{/key}
</div>
<div class="col-4" style="text-align: center;" bind:clientWidth={w2}>
<b>Number of Nodes Distribution</b>
<div class="col-4 text-center" bind:clientWidth={w2}>
{#key $stats.data.jobsStatistics[0].histNumNodes}
{#if resize == true}
<Histogram
data={$stats.data.jobsStatistics[0].histNumNodes}
data={convert2uplot($stats.data.jobsStatistics[0].histNumNodes)}
width={w2 - 25} height={histogramHeight}
xlabel="Allocated Nodes [#]"
ylabel="Number of Jobs" />
{:else}
<Histogram
data={$stats.data.jobsStatistics[0].histNumNodes}
width={400} height={250}
xlabel="Allocated Nodes [#]"
ylabel="Number of Jobs" />
{/if}
title="Number of Nodes Distribution"
xlabel="Allocated Nodes"
xunit="Nodes"
ylabel="Number of Jobs"
yunit="Jobs"/>
{/key}
</div>
{/if}

View File

@@ -7,6 +7,7 @@
export let customEnabled = true
export let anyEnabled = false
export let options = {
'Last quarter hour': 15*60,
'Last half hour': 30*60,
'Last hour': 60*60,
'Last 2hrs': 2*60*60,

View File

@@ -7,7 +7,10 @@
-->
<script context="module">
export const scrambleNames = window.localStorage.getItem("cc-scramble-names")
export const scramble = (str) => [...str].reduce((x, c, i) => x * 7 + c.charCodeAt(0) * i * 21, 5).toString(32)
export const scramble = function(str) {
if (str === '-') return str
else return [...str].reduce((x, c, i) => x * 7 + c.charCodeAt(0) * i * 21, 5).toString(32).substr(0, 6)
}
</script>
<script>
import Tag from '../Tag.svelte';

View File

@@ -136,6 +136,7 @@
{cluster}
subCluster={job.subCluster}
isShared={(job.exclusive != 1)}
resources={job.resources}
/>
{:else if metric.disabled == true && metric.data}
<Card body color="info">Metric disabled for subcluster <code>{metric.data.name}:{job.subCluster}</code></Card>

View File

@@ -1,229 +1,216 @@
<!--
@component
Properties:
- width, height: Number
- min, max: Number
- label: (x-Value) => String
- data: [{ value: Number, count: Number }]
- Todo
-->
<div
on:mousemove={mousemove}
on:mouseleave={() => (infoText = '')}>
<span style="left: {paddingLeft + 5}px;">{infoText}</span>
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
</div>
<script>
import { onMount } from 'svelte'
import uPlot from 'uplot'
import { formatNumber } from '../units.js'
import { onMount, onDestroy } from 'svelte'
import { Card } from 'sveltestrap'
export let data
export let width = 500
export let height = 300
export let title = ''
export let xlabel = ''
export let xunit = 'X'
export let ylabel = ''
export let min = null
export let max = null
export let small = false
export let label = formatNumber
export let yunit = 'Y'
const fontSize = 12
const fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
const paddingLeft = 50, paddingRight = 20, paddingTop = 20, paddingBottom = 20
const { bars } = uPlot.paths
let ctx, canvasElement
const drawStyles = {
bars: 1,
points: 2,
};
const maxCount = data.reduce((max, point) => Math.max(max, point.count), 0),
maxValue = data.reduce((max, point) => Math.max(max, point.value), 0.1)
function paths(u, seriesIdx, idx0, idx1, extendGap, buildClip) {
let s = u.series[seriesIdx];
let style = s.drawStyle;
function getStepSize(valueRange, pixelRange, minSpace) {
const proposition = valueRange / (pixelRange / minSpace)
const getStepSize = n => Math.pow(10, Math.floor(n / 3)) *
(n < 0 ? [1., 5., 2.][-n % 3] : [1., 2., 5.][n % 3])
let renderer = ( // If bars to wide, change here
style == drawStyles.bars ? (
bars({size: [0.75, 100]})
) :
() => null
)
let n = 0
let stepsize = getStepSize(n)
while (true) {
let bigger = getStepSize(n + 1)
if (proposition > bigger) {
n += 1
stepsize = bigger
} else {
return stepsize
return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip);
}
// converts the legend into a simple tooltip
function legendAsTooltipPlugin({ className, style = { backgroundColor:"rgba(255, 249, 196, 0.92)", color: "black" } } = {}) {
let legendEl;
function init(u, opts) {
legendEl = u.root.querySelector(".u-legend");
legendEl.classList.remove("u-inline");
className && legendEl.classList.add(className);
uPlot.assign(legendEl.style, {
textAlign: "left",
pointerEvents: "none",
display: "none",
position: "absolute",
left: 0,
top: 0,
zIndex: 100,
boxShadow: "2px 2px 10px rgba(0,0,0,0.5)",
...style
});
// hide series color markers
const idents = legendEl.querySelectorAll(".u-marker");
for (let i = 0; i < idents.length; i++)
idents[i].style.display = "none";
const overEl = u.over;
overEl.style.overflow = "visible";
// move legend into plot bounds
overEl.appendChild(legendEl);
// show/hide tooltip on enter/exit
overEl.addEventListener("mouseenter", () => {legendEl.style.display = null;});
overEl.addEventListener("mouseleave", () => {legendEl.style.display = "none";});
// let tooltip exit plot
// overEl.style.overflow = "visible";
}
function update(u) {
const { left, top } = u.cursor;
legendEl.style.transform = "translate(" + (left + 15) + "px, " + (top + 15) + "px)";
}
return {
hooks: {
init: init,
setCursor: update,
}
}
}
let infoText = ''
function mousemove(event) {
let rect = event.target.getBoundingClientRect()
let x = event.clientX - rect.left
if (x < paddingLeft || x > width - paddingRight) {
infoText = ''
return
}
const w = width - paddingLeft - paddingRight
const barWidth = Math.round(w / (maxValue + 1))
x = Math.floor((x - paddingLeft) / (w - barWidth) * maxValue)
let point = data.find(point => point.value == x)
if (point)
infoText = `count: ${point.count} (value: ${label(x)})`
else
infoText = ''
};
}
let plotWrapper = null
let uplot = null
let timeoutId = null
function render() {
const labelOffset = Math.floor(height * 0.1)
const h = height - paddingTop - paddingBottom - labelOffset
const w = width - paddingLeft - paddingRight
const barGap = 5
const barWidth = Math.ceil(w / (maxValue + 1)) - barGap
let opts = {
width: width,
height: height,
title: title,
plugins: [
legendAsTooltipPlugin()
],
cursor: {
points: {
size: (u, seriesIdx) => u.series[seriesIdx].points.size * 2.5,
width: (u, seriesIdx, size) => size / 4,
stroke: (u, seriesIdx) => u.series[seriesIdx].points.stroke(u, seriesIdx) + '90',
fill: (u, seriesIdx) => "#fff",
}
},
scales: {
x: {
time: false
},
},
axes: [
{
stroke: "#000000",
// scale: 'x',
label: xlabel,
labelGap: 10,
size: 25,
incrs: [1, 2, 5, 6, 10, 12, 50, 100, 500, 1000, 5000, 10000],
border: {
show: true,
stroke: "#000000",
},
ticks: {
width: 1 / devicePixelRatio,
size: 5 / devicePixelRatio,
stroke: "#000000",
},
values: (_, t) => t.map(v => formatNumber(v)),
},
{
stroke: "#000000",
// scale: 'y',
label: ylabel,
labelGap: 10,
size: 35,
border: {
show: true,
stroke: "#000000",
},
ticks: {
width: 1 / devicePixelRatio,
size: 5 / devicePixelRatio,
stroke: "#000000",
},
values: (_, t) => t.map(v => formatNumber(v)),
},
],
series: [
{
label: xunit !== '' ? xunit : null,
},
Object.assign({
label: yunit !== '' ? yunit : null,
width: 1 / devicePixelRatio,
drawStyle: drawStyles.points,
lineInterpolation: null,
paths,
}, {
drawStyle: drawStyles.bars,
lineInterpolation: null,
stroke: "#85abce",
fill: "#85abce", // + "1A", // Transparent Fill
}),
]
};
if (Number.isNaN(barWidth))
return
uplot = new uPlot(opts, data, plotWrapper)
}
const getCanvasX = (value) => (value / maxValue) * (w - barWidth) + paddingLeft + (barWidth / 2.)
const getCanvasY = (count) => (h - (count / maxCount) * h) + paddingTop
// X Axis
ctx.font = `bold ${fontSize}px ${fontFamily}`
ctx.fillStyle = 'black'
if (xlabel != '') {
let textWidth = ctx.measureText(xlabel).width
ctx.fillText(xlabel, Math.floor((width / 2) - (textWidth / 2) + barGap), height - Math.floor(labelOffset / 2))
}
ctx.textAlign = 'center'
ctx.font = `${fontSize}px ${fontFamily}`
if (min != null && max != null) {
const stepsizeX = getStepSize(max - min, w, 75)
let startX = 0
while (startX < min)
startX += stepsizeX
for (let x = startX; x < max; x += stepsizeX) {
let px = ((x - min) / (max - min)) * (w - barWidth) + paddingLeft + (barWidth / 2.)
ctx.fillText(`${formatNumber(x)}`, px, height - paddingBottom - Math.floor(labelOffset / 2))
}
} else {
const stepsizeX = getStepSize(maxValue, w, 120)
for (let x = 0; x <= maxValue; x += stepsizeX) {
ctx.fillText(label(x), getCanvasX(x), height - paddingBottom - Math.floor(labelOffset / (small ? 8 : 2)))
}
}
// Y Axis
ctx.fillStyle = 'black'
ctx.strokeStyle = '#bbbbbb'
ctx.font = `bold ${fontSize}px ${fontFamily}`
if (ylabel != '') {
ctx.save()
ctx.translate(15, Math.floor(h / 2))
ctx.rotate(-Math.PI / 2)
ctx.fillText(ylabel, 0, 0)
ctx.restore()
}
ctx.textAlign = 'right'
ctx.font = `${fontSize}px ${fontFamily}`
ctx.beginPath()
const stepsizeY = getStepSize(maxCount, h, 50)
for (let y = stepsizeY; y <= maxCount; y += stepsizeY) {
const py = Math.floor(getCanvasY(y))
ctx.fillText(`${formatNumber(y)}`, paddingLeft - 5, py)
ctx.moveTo(paddingLeft, py)
ctx.lineTo(width, py)
}
ctx.stroke()
// Draw bars
ctx.fillStyle = '#85abce'
for (let p of data) {
ctx.fillRect(
getCanvasX(p.value) - (barWidth / 2.),
getCanvasY(p.count),
barWidth,
(p.count / maxCount) * h)
}
// Fat lines left and below plotting area
ctx.strokeStyle = 'black'
ctx.beginPath()
ctx.moveTo(0, height - paddingBottom - labelOffset)
ctx.lineTo(width, height - paddingBottom - labelOffset)
ctx.moveTo(paddingLeft, 0)
ctx.lineTo(paddingLeft, height - Math.floor(labelOffset / 2))
ctx.stroke()
}
let mounted = false
onMount(() => {
mounted = true
canvasElement.width = width
canvasElement.height = height
ctx = canvasElement.getContext('2d')
render()
})
let timeoutId = null;
onDestroy(() => {
if (uplot)
uplot.destroy()
if (timeoutId != null)
clearTimeout(timeoutId)
})
function sizeChanged() {
if (timeoutId != null)
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null
if (!canvasElement)
return
if (uplot)
uplot.destroy()
canvasElement.width = width
canvasElement.height = height
ctx = canvasElement.getContext('2d')
render()
}, 250)
}, 200)
}
$: sizeChanged(width, height)
</script>
<style>
div {
position: relative;
}
div > span {
position: absolute;
top: 0px;
}
</style>
{#if data.length > 0}
<div bind:this={plotWrapper}/>
{:else}
<Card class="mx-4" body color="warning">Cannot render histogram: No data!</Card>
{/if}
<script context="module">
import { formatNumber } from '../units.js'
export function binsFromFootprint(weights, values, numBins) {
let min = 0, max = 0
if (values.length != 0) {
for (let x of values) {
min = Math.min(min, x)
max = Math.max(max, x)
}
max += 1 // So that we have an exclusive range.
}
if (numBins == null || numBins < 3)
numBins = 3
const bins = new Array(numBins).fill(0)
for (let i = 0; i < values.length; i++)
bins[Math.floor(((values[i] - min) / (max - min)) * numBins)] += weights ? weights[i] : 1
return {
label: idx => {
let start = min + (idx / numBins) * (max - min)
let stop = min + ((idx + 1) / numBins) * (max - min)
return `${formatNumber(start)} - ${formatNumber(stop)}`
},
bins: bins.map((count, idx) => ({ value: idx, count: count })),
min: min,
max: max
}
}
</script>

View File

@@ -26,17 +26,19 @@
import { getContext, onMount, onDestroy } from 'svelte'
import { Card } from 'sveltestrap'
export let metric
export let scope = 'node'
export let resources = []
export let width
export let height
export let timestep
export let series
export let useStatsSeries = null
export let statisticsSeries = null
export let cluster
export let subCluster
export let metric
export let useStatsSeries = null
export let scope = 'node'
export let isShared = false
export let forNode = false
if (useStatsSeries == null)
useStatsSeries = statisticsSeries != null
@@ -53,6 +55,70 @@
const backgroundColors = { normal: 'rgba(255, 255, 255, 1.0)', caution: 'rgba(255, 128, 0, 0.3)', alert: 'rgba(255, 0, 0, 0.3)' }
const thresholds = findThresholds(metricConfig, scope, typeof subCluster == 'string' ? cluster.subClusters.find(sc => sc.name == subCluster) : subCluster)
// converts the legend into a simple tooltip
function legendAsTooltipPlugin({ className, style = { backgroundColor:"rgba(255, 249, 196, 0.92)", color: "black" } } = {}) {
let legendEl;
const dataSize = series.length
function init(u, opts) {
legendEl = u.root.querySelector(".u-legend");
legendEl.classList.remove("u-inline");
className && legendEl.classList.add(className);
uPlot.assign(legendEl.style, {
textAlign: "left",
pointerEvents: "none",
display: "none",
position: "absolute",
left: 0,
top: 0,
zIndex: 100,
boxShadow: "2px 2px 10px rgba(0,0,0,0.5)",
...style
});
// conditional hide series color markers:
if (useStatsSeries === true || // Min/Max/Avg Self-Explanatory
dataSize === 1 || // Only one Y-Dataseries
dataSize > 6 ){ // More than 6 Y-Dataseries
const idents = legendEl.querySelectorAll(".u-marker");
for (let i = 0; i < idents.length; i++)
idents[i].style.display = "none";
}
const overEl = u.over;
overEl.style.overflow = "visible";
// move legend into plot bounds
overEl.appendChild(legendEl);
// show/hide tooltip on enter/exit
overEl.addEventListener("mouseenter", () => {legendEl.style.display = null;});
overEl.addEventListener("mouseleave", () => {legendEl.style.display = "none";});
// let tooltip exit plot
// overEl.style.overflow = "visible";
}
function update(u) {
const { left, top } = u.cursor;
const width = u.over.querySelector(".u-legend").offsetWidth;
legendEl.style.transform = "translate(" + (left - width - 15) + "px, " + (top + 15) + "px)";
}
if (dataSize <= 12 || useStatsSeries === true) {
return {
hooks: {
init: init,
setCursor: update,
}
}
} else { // Setting legend-opts show/live as object with false here will not work ...
return {}
}
}
function backgroundColor() {
if (clusterCockpitConfig.plot_general_colorBackground == false
|| !thresholds
@@ -88,24 +154,48 @@
? statisticsSeries.mean.length
: series.reduce((n, series) => Math.max(n, series.data.length), 0)
const maxX = longestSeries * timestep
const maxY = thresholds != null
? useStatsSeries
let maxY = null
if (thresholds !== null) {
maxY = useStatsSeries
? (statisticsSeries.max.reduce((max, x) => Math.max(max, x), thresholds.normal) || thresholds.normal)
: (series.reduce((max, series) => Math.max(max, series.statistics?.max), thresholds.normal) || thresholds.normal)
: null
const plotSeries = [{}]
if (maxY >= (10 * thresholds.normal)) { // Hard y-range render limit if outliers in series data
maxY = (10 * thresholds.normal)
}
}
const plotSeries = [{label: 'Runtime', value: (u, ts, sidx, didx) => didx == null ? null : formatTime(ts)}]
const plotData = [new Array(longestSeries)]
for (let i = 0; i < longestSeries; i++) // TODO: Cache/Reuse this array?
plotData[0][i] = i * timestep
if (forNode === true) {
// Negative Timestamp Buildup
for (let i = 0; i <= longestSeries; i++) {
plotData[0][i] = (longestSeries - i) * timestep * -1
}
} else {
// Positive Timestamp Buildup
for (let j = 0; j < longestSeries; j++) // TODO: Cache/Reuse this array?
plotData[0][j] = j * timestep
}
let plotBands = undefined
if (useStatsSeries) {
plotData.push(statisticsSeries.min)
plotData.push(statisticsSeries.max)
plotData.push(statisticsSeries.mean)
plotSeries.push({ scale: 'y', width: lineWidth, stroke: 'red' })
plotSeries.push({ scale: 'y', width: lineWidth, stroke: 'green' })
plotSeries.push({ scale: 'y', width: lineWidth, stroke: 'black' })
if (forNode === true) { // timestamp 0 with null value for reversed time axis
if (plotData[1].length != 0) plotData[1].push(null)
if (plotData[2].length != 0) plotData[2].push(null)
if (plotData[3].length != 0) plotData[3].push(null)
}
plotSeries.push({ label: 'min', scale: 'y', width: lineWidth, stroke: 'red' })
plotSeries.push({ label: 'max', scale: 'y', width: lineWidth, stroke: 'green' })
plotSeries.push({ label: 'mean', scale: 'y', width: lineWidth, stroke: 'black' })
plotBands = [
{ series: [2,3], fill: 'rgba(0,255,0,0.1)' },
{ series: [3,1], fill: 'rgba(255,0,0,0.1)' }
@@ -113,7 +203,11 @@
} else {
for (let i = 0; i < series.length; i++) {
plotData.push(series[i].data)
if (forNode === true && plotData[1].length != 0) plotData[1].push(null) // timestamp 0 with null value for reversed time axis
plotSeries.push({
label: scope === 'node' ? resources[i].hostname :
// scope === 'accelerator' ? resources[0].accelerators[i] :
scope + ' #' + (i+1),
scale: 'y',
width: lineWidth,
stroke: lineColor(i, series.length)
@@ -124,12 +218,15 @@
const opts = {
width,
height,
plugins: [
legendAsTooltipPlugin()
],
series: plotSeries,
axes: [
{
scale: 'x',
space: 35,
incrs: timeIncrs(timestep, maxX),
incrs: timeIncrs(timestep, maxX, forNode),
values: (_, vals) => vals.map(v => formatTime(v))
},
{
@@ -177,8 +274,11 @@
x: { time: false },
y: maxY ? { range: [0., maxY * 1.1] } : {}
},
cursor: { show: false },
legend: { show: false, live: false }
legend : { // Display legend until max 12 Y-dataseries
show: (series.length <= 12 || useStatsSeries === true) ? true : false,
live: (series.length <= 12 || useStatsSeries === true) ? true : false
},
cursor: { drag: { x: true, y: true } }
}
// console.log(opts)
@@ -249,24 +349,33 @@
}
</script>
<script context="module">
export function formatTime(t) {
let h = Math.floor(t / 3600)
let m = Math.floor((t % 3600) / 60)
if (h == 0)
return `${m}m`
else if (m == 0)
return `${h}h`
else
return `${h}:${m}h`
if (t !== null) {
if (isNaN(t)) {
return t
} else {
let h = Math.floor(t / 3600)
let m = Math.floor((t % 3600) / 60)
if (h == 0)
return `${m}m`
else if (m == 0)
return `${h}h`
else
return `${h}:${m}h`
}
}
}
export function timeIncrs(timestep, maxX) {
let incrs = []
for (let t = timestep; t < maxX; t *= 10)
incrs.push(t, t * 2, t * 3, t * 5)
export function timeIncrs(timestep, maxX, forNode) {
if (forNode === true) {
return [60, 300, 900, 1800, 3600, 7200, 14400, 21600] // forNode fixed increments
} else {
let incrs = []
for (let t = timestep; t < maxX; t *= 10)
incrs.push(t, t * 2, t * 3, t * 5)
return incrs
return incrs
}
}
export function findThresholds(metricConfig, scope, subCluster) {
@@ -323,8 +432,9 @@
{#if series[0].data.length > 0}
<div bind:this={plotWrapper} class="cc-plot"></div>
{:else}
<Card style="margin-left: 2rem;margin-right: 2rem;" body color="warning">Cannot render plot: No series data returned for <code>{metric}</code></Card>
<Card class="mx-4" body color="warning">Cannot render plot: No series data returned for <code>{metric}</code></Card>
{/if}
<style>
.cc-plot {
border-radius: 5px;

View File

@@ -0,0 +1,81 @@
<script context="module">
// http://tsitsul.in/blog/coloropt/ : 12 colors normal
export const colors = [
'rgb(235,172,35)',
'rgb(184,0,88)',
'rgb(0,140,249)',
'rgb(0,110,0)',
'rgb(0,187,173)',
'rgb(209,99,230)',
'rgb(178,69,2)',
'rgb(255,146,135)',
'rgb(89,84,214)',
'rgb(0,198,248)',
'rgb(135,133,0)',
'rgb(0,167,108)',
'rgb(189,189,189)'
]
</script>
<script>
import { Pie } from 'svelte-chartjs';
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
Filler,
ArcElement,
CategoryScale
} from 'chart.js';
ChartJS.register(
Title,
Tooltip,
Legend,
Filler,
ArcElement,
CategoryScale
);
export let size
export let sliceLabel
export let quantities
export let entities
export let displayLegend = false
const data = {
labels: entities,
datasets: [
{
label: sliceLabel,
data: quantities,
fill: 1,
backgroundColor: colors.slice(0, quantities.length),
}
]
}
const options = {
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
display: displayLegend
}
}
}
</script>
<div class="chart-container" style="--container-width: {size}; --container-height: {size}">
<Pie {data} {options}/>
</div>
<style>
.chart-container {
position: relative;
margin: auto;
height: var(--container-height);
width: var(--container-width);
}
</style>

View File

@@ -1,22 +1,34 @@
<div>
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
</div>
<script>
import { onMount, getContext } from 'svelte'
import { getContext } from 'svelte'
import { Radar } from 'svelte-chartjs';
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
Filler,
PointElement,
RadialLinearScale,
LineElement
} from 'chart.js';
ChartJS.register(
Title,
Tooltip,
Legend,
Filler,
PointElement,
RadialLinearScale,
LineElement
);
export let size
export let metrics
export let width
export let height
export let cluster
export let jobMetrics
const fontSize = 12
const fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
const metricConfig = getContext('metrics')
let ctx, canvasElement
const labels = metrics.filter(name => {
if (!jobMetrics.find(m => m.name == name && m.scope == "node")) {
console.warn(`PolarPlot: No metric data for '${name}'`)
@@ -46,145 +58,49 @@
return avg / metric.series.length
}
const data = [
{
name: 'Max',
values: getValuesForStat(getMax),
color: 'rgb(0, 102, 255)',
areaColor: 'rgba(0, 102, 255, 0.25)'
},
{
name: 'Avg',
values: getValuesForStat(getAvg),
color: 'rgb(255, 153, 0)',
areaColor: 'rgba(255, 153, 0, 0.25)'
}
]
function render() {
if (!width || Number.isNaN(width))
return
const centerX = width / 2
const centerY = height / 2 - 15
const radius = (Math.min(width, height) / 2) - 50
// Draw circles
ctx.lineWidth = 1
ctx.strokeStyle = '#999999'
ctx.beginPath()
ctx.arc(centerX, centerY, radius * 1.0, 0, Math.PI * 2, false)
ctx.stroke()
ctx.beginPath()
ctx.arc(centerX, centerY, radius * 0.666, 0, Math.PI * 2, false)
ctx.stroke()
ctx.beginPath()
ctx.arc(centerX, centerY, radius * 0.333, 0, Math.PI * 2, false)
ctx.stroke()
// Axis
ctx.font = `${fontSize}px ${fontFamily}`
ctx.textAlign = 'center'
ctx.fillText('1/3',
Math.floor(centerX + radius * 0.333),
Math.floor(centerY + 15))
ctx.fillText('2/3',
Math.floor(centerX + radius * 0.666),
Math.floor(centerY + 15))
ctx.fillText('1.0',
Math.floor(centerX + radius * 1.0),
Math.floor(centerY + 15))
// Label text and straight lines from center
for (let i = 0; i < labels.length; i++) {
const angle = 2 * Math.PI * ((i + 1) / labels.length)
const dx = Math.cos(angle) * radius
const dy = Math.sin(angle) * radius
ctx.fillText(labels[i],
Math.floor(centerX + dx * 1.1),
Math.floor(centerY + dy * 1.1))
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.lineTo(centerX + dx, centerY + dy)
ctx.stroke()
}
for (let dataset of data) {
console.assert(dataset.values.length === labels.length, 'this will look confusing')
ctx.fillStyle = dataset.color
ctx.strokeStyle = dataset.color
const points = []
for (let i = 0; i < dataset.values.length; i++) {
const value = dataset.values[i]
const angle = 2 * Math.PI * ((i + 1) / labels.length)
const x = centerX + Math.cos(angle) * radius * value
const y = centerY + Math.sin(angle) * radius * value
ctx.beginPath()
ctx.arc(x, y, 3, 0, Math.PI * 2, false)
ctx.fill()
points.push({ x, y })
const data = {
labels: labels,
datasets: [
{
label: 'Max',
data: getValuesForStat(getMax),
fill: 1,
backgroundColor: 'rgba(0, 102, 255, 0.25)',
borderColor: 'rgb(0, 102, 255)',
pointBackgroundColor: 'rgb(0, 102, 255)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgb(0, 102, 255)'
},
{
label: 'Avg',
data: getValuesForStat(getAvg),
fill: true,
backgroundColor: 'rgba(255, 153, 0, 0.25)',
borderColor: 'rgb(255, 153, 0)',
pointBackgroundColor: 'rgb(255, 153, 0)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgb(255, 153, 0)'
}
// "Fill" the shape this dataset has
ctx.fillStyle = dataset.areaColor
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let p of points)
ctx.lineTo(p.x, p.y)
ctx.lineTo(points[0].x, points[0].y)
ctx.stroke()
ctx.fill()
}
// Legend at the bottom left corner
ctx.textAlign = 'left'
let paddingLeft = 0
for (let dataset of data) {
const text = `${dataset.name}: `
const textWidth = ctx.measureText(text).width
ctx.fillStyle = 'black'
ctx.fillText(text, paddingLeft, height - 20)
ctx.fillStyle = dataset.color
ctx.beginPath()
ctx.arc(paddingLeft + textWidth + 5, height - 25, 5, 0, Math.PI * 2, false)
ctx.fill()
paddingLeft += textWidth + 15
}
ctx.fillStyle = 'black'
ctx.fillText(`Values relative to respective peak.`, 0, height - 7)
]
}
let mounted = false
onMount(() => {
canvasElement.width = width
canvasElement.height = height
ctx = canvasElement.getContext('2d')
render(ctx, data, width, height)
mounted = true
})
let timeoutId = null
function sizeChanged() {
if (!mounted)
return;
if (timeoutId != null)
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null
canvasElement.width = width
canvasElement.height = height
ctx = canvasElement.getContext('2d')
render(ctx, data, width, height)
}, 250)
// No custom defined options but keep for clarity
const options = {
maintainAspectRatio: false,
animation: false
}
$: sizeChanged(width, height)
</script>
<div class="chart-container">
<Radar {data} {options} width={size} height={size}/>
</div>
<style>
.chart-container {
margin: auto;
position: relative;
}
</style>

View File

@@ -6,11 +6,15 @@ const power = [1, 1e3, 1e6, 1e9, 1e12, 1e15, 1e18, 1e21]
const prefix = ['', 'K', 'M', 'G', 'T', 'P', 'E']
export function formatNumber(x) {
for (let i = 0; i < prefix.length; i++)
if (power[i] <= x && x < power[i+1])
return `${Math.round((x / power[i]) * 100) / 100} ${prefix[i]}`
if ( isNaN(x) ) {
return x // Return if String , used in Histograms
} else {
for (let i = 0; i < prefix.length; i++)
if (power[i] <= x && x < power[i+1])
return `${Math.round((x / power[i]) * 100) / 100} ${prefix[i]}`
return Math.abs(x) >= 1000 ? x.toExponential() : x.toString()
return Math.abs(x) >= 1000 ? x.toExponential() : x.toString()
}
}
export function scaleNumbers(x, y , p = '') {

View File

@@ -6,6 +6,7 @@ import {
} from "@urql/svelte";
import { setContext, getContext, hasContext, onDestroy, tick } from "svelte";
import { readable } from "svelte/store";
import { formatNumber } from './units.js'
/*
* Call this function only at component initialization time!
@@ -313,3 +314,55 @@ export function checkMetricDisabled(m, c, s) { //[m]etric, [c]luster, [s]ubclust
}
return false;
}
export function convert2uplot(canvasData) {
// initial use: Canvas Histogram Data to Uplot
let uplotData = [[],[]] // [X, Y1, Y2, ...]
canvasData.forEach( pair => {
uplotData[0].push(pair.value)
uplotData[1].push(pair.count)
})
return uplotData
}
export function binsFromFootprint(weights, values, numBins) {
let min = 0, max = 0
if (values.length != 0) {
for (let x of values) {
min = Math.min(min, x)
max = Math.max(max, x)
}
max += 1 // So that we have an exclusive range.
}
if (numBins == null || numBins < 3)
numBins = 3
const bins = new Array(numBins).fill(0)
for (let i = 0; i < values.length; i++)
bins[Math.floor(((values[i] - min) / (max - min)) * numBins)] += weights ? weights[i] : 1
// return {
// label: idx => {
// let start = min + (idx / numBins) * (max - min)
// let stop = min + ((idx + 1) / numBins) * (max - min)
// return `${formatNumber(start)} - ${formatNumber(stop)}`
// },
// bins: bins.map((count, idx) => ({ value: idx, count: count })),
// min: min,
// max: max
// }
return {
bins: bins.map((count, idx) => ({
value: idx => { // Get rounded down next integer to bins' Start-Stop Mean Value
let start = min + (idx / numBins) * (max - min)
let stop = min + ((idx + 1) / numBins) * (max - min)
return `${formatNumber(Math.floor((start+stop)/2))}`
},
count: count
})),
min: min,
max: max
}
}