mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-09-13 04:02:59 +02:00
Merge branch 'dev' into metricstore
This commit is contained in:
@@ -459,7 +459,7 @@
|
||||
</tr>
|
||||
{#each $topQuery.data.topList as te, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {colors[i]};" /></td>
|
||||
<td><Icon name="circle-fill" style="color: {colors['colorblind'][i]};" /></td>
|
||||
{#if groupSelection.key == "user"}
|
||||
<th scope="col" id="topName-{te.id}"
|
||||
><a href="/monitoring/user/{te.id}?cluster={clusterName}"
|
||||
|
@@ -2,709 +2,62 @@
|
||||
@component Main cluster status view component; renders current system-usage information
|
||||
|
||||
Properties:
|
||||
- `cluster String`: The cluster to show status information for
|
||||
- `presetCluster String`: The cluster to show status information for
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import {
|
||||
getContext
|
||||
} from "svelte"
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Spinner,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardBody,
|
||||
Table,
|
||||
Progress,
|
||||
Icon,
|
||||
Button,
|
||||
Tooltip
|
||||
TabContent,
|
||||
TabPane
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
getContextClient,
|
||||
mutationStore,
|
||||
} from "@urql/svelte";
|
||||
import {
|
||||
init,
|
||||
convert2uplot,
|
||||
transformPerNodeDataForRoofline,
|
||||
scramble,
|
||||
scrambleNames,
|
||||
} from "./generic/utils.js";
|
||||
import { scaleNumbers } from "./generic/units.js";
|
||||
import PlotGrid from "./generic/PlotGrid.svelte";
|
||||
import Roofline from "./generic/plots/Roofline.svelte";
|
||||
import Pie, { colors } from "./generic/plots/Pie.svelte";
|
||||
import Histogram from "./generic/plots/Histogram.svelte";
|
||||
import Refresher from "./generic/helper/Refresher.svelte";
|
||||
import HistogramSelection from "./generic/select/HistogramSelection.svelte";
|
||||
|
||||
import StatusDash from "./status/StatusDash.svelte";
|
||||
import UsageDash from "./status/UsageDash.svelte";
|
||||
import StatisticsDash from "./status/StatisticsDash.svelte";
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
cluster
|
||||
presetCluster
|
||||
} = $props();
|
||||
|
||||
/* Const Init */
|
||||
const { query: initq } = init();
|
||||
const ccconfig = getContext("cc-config");
|
||||
const client = getContextClient();
|
||||
const paging = { itemsPerPage: 10, page: 1 }; // Top 10
|
||||
const topOptions = [
|
||||
{ key: "totalJobs", label: "Jobs" },
|
||||
{ key: "totalNodes", label: "Nodes" },
|
||||
{ key: "totalCores", label: "Cores" },
|
||||
{ key: "totalAccs", label: "Accelerators" },
|
||||
];
|
||||
/*Const Init */
|
||||
const useCbColors = getContext("cc-config")?.plot_general_colorblindMode || false
|
||||
|
||||
/* State Init */
|
||||
let from = $state(new Date(Date.now() - 5 * 60 * 1000));
|
||||
let to = $state(new Date(Date.now()));
|
||||
let colWidth = $state(0);
|
||||
let plotWidths = $state([]);
|
||||
// Histrogram
|
||||
let isHistogramSelectionOpen = $state(false);
|
||||
let selectedHistograms = $state(cluster
|
||||
? ccconfig[`user_view_histogramMetrics:${cluster}`] || ( ccconfig['user_view_histogramMetrics'] || [] )
|
||||
: ccconfig['user_view_histogramMetrics'] || []);
|
||||
// Bar Gauges
|
||||
let allocatedNodes = $state({});
|
||||
let flopRate = $state({});
|
||||
let flopRateUnitPrefix = $state({});
|
||||
let flopRateUnitBase = $state({});
|
||||
let memBwRate = $state({});
|
||||
let memBwRateUnitPrefix = $state({});
|
||||
let memBwRateUnitBase = $state({});
|
||||
// Pie Charts
|
||||
let topProjectSelection = $state(
|
||||
topOptions.find(
|
||||
(option) =>
|
||||
option.key ==
|
||||
ccconfig[`status_view_selectedTopProjectCategory:${cluster}`],
|
||||
) ||
|
||||
topOptions.find(
|
||||
(option) => option.key == ccconfig.status_view_selectedTopProjectCategory,
|
||||
)
|
||||
);
|
||||
let topUserSelection = $state(
|
||||
topOptions.find(
|
||||
(option) =>
|
||||
option.key ==
|
||||
ccconfig[`status_view_selectedTopUserCategory:${cluster}`],
|
||||
) ||
|
||||
topOptions.find(
|
||||
(option) => option.key == ccconfig.status_view_selectedTopUserCategory,
|
||||
)
|
||||
);
|
||||
|
||||
/* Derived */
|
||||
// Note: nodeMetrics are requested on configured $timestep resolution
|
||||
const mainQuery = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query (
|
||||
$cluster: String!
|
||||
$filter: [JobFilter!]!
|
||||
$metrics: [String!]
|
||||
$from: Time!
|
||||
$to: Time!
|
||||
$selectedHistograms: [String!]
|
||||
) {
|
||||
nodeMetrics(
|
||||
cluster: $cluster
|
||||
metrics: $metrics
|
||||
from: $from
|
||||
to: $to
|
||||
) {
|
||||
host
|
||||
subCluster
|
||||
metrics {
|
||||
name
|
||||
scope
|
||||
metric {
|
||||
timestep
|
||||
unit {
|
||||
base
|
||||
prefix
|
||||
}
|
||||
series {
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats: jobsStatistics(filter: $filter, metrics: $selectedHistograms) {
|
||||
histDuration {
|
||||
count
|
||||
value
|
||||
}
|
||||
histNumNodes {
|
||||
count
|
||||
value
|
||||
}
|
||||
histNumCores {
|
||||
count
|
||||
value
|
||||
}
|
||||
histNumAccs {
|
||||
count
|
||||
value
|
||||
}
|
||||
histMetrics {
|
||||
metric
|
||||
unit
|
||||
data {
|
||||
min
|
||||
max
|
||||
count
|
||||
bin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allocatedNodes(cluster: $cluster) {
|
||||
name
|
||||
count
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
cluster: cluster,
|
||||
metrics: ["flops_any", "mem_bw"], // Fixed names for roofline and status bars
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
filter: [{ state: ["running"] }, { cluster: { eq: cluster } }],
|
||||
selectedHistograms: selectedHistograms,
|
||||
},
|
||||
}));
|
||||
|
||||
const topUserQuery = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query (
|
||||
$filter: [JobFilter!]!
|
||||
$paging: PageRequest!
|
||||
$sortBy: SortByAggregate!
|
||||
) {
|
||||
topUser: jobsStatistics(
|
||||
filter: $filter
|
||||
page: $paging
|
||||
sortBy: $sortBy
|
||||
groupBy: USER
|
||||
) {
|
||||
id
|
||||
name
|
||||
totalJobs
|
||||
totalNodes
|
||||
totalCores
|
||||
totalAccs
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ state: ["running"] }, { cluster: { eq: cluster } }],
|
||||
paging,
|
||||
sortBy: topUserSelection.key.toUpperCase(),
|
||||
},
|
||||
}));
|
||||
|
||||
const topProjectQuery = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query (
|
||||
$filter: [JobFilter!]!
|
||||
$paging: PageRequest!
|
||||
$sortBy: SortByAggregate!
|
||||
) {
|
||||
topProjects: jobsStatistics(
|
||||
filter: $filter
|
||||
page: $paging
|
||||
sortBy: $sortBy
|
||||
groupBy: PROJECT
|
||||
) {
|
||||
id
|
||||
totalJobs
|
||||
totalNodes
|
||||
totalCores
|
||||
totalAccs
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ state: ["running"] }, { cluster: { eq: cluster } }],
|
||||
paging,
|
||||
sortBy: topProjectSelection.key.toUpperCase(),
|
||||
},
|
||||
}));
|
||||
|
||||
/* Effects */
|
||||
$effect(() => {
|
||||
if ($initq.data && $mainQuery.data) {
|
||||
let subClusters = $initq.data.clusters.find(
|
||||
(c) => c.name == cluster,
|
||||
).subClusters;
|
||||
for (let subCluster of subClusters) {
|
||||
allocatedNodes[subCluster.name] =
|
||||
$mainQuery.data.allocatedNodes.find(
|
||||
({ name }) => name == subCluster.name,
|
||||
)?.count || 0;
|
||||
flopRate[subCluster.name] =
|
||||
Math.floor(
|
||||
sumUp($mainQuery.data.nodeMetrics, subCluster.name, "flops_any") *
|
||||
100,
|
||||
) / 100;
|
||||
flopRateUnitPrefix[subCluster.name] = subCluster.flopRateSimd.unit.prefix;
|
||||
flopRateUnitBase[subCluster.name] = subCluster.flopRateSimd.unit.base;
|
||||
memBwRate[subCluster.name] =
|
||||
Math.floor(
|
||||
sumUp($mainQuery.data.nodeMetrics, subCluster.name, "mem_bw") * 100,
|
||||
) / 100;
|
||||
memBwRateUnitPrefix[subCluster.name] =
|
||||
subCluster.memoryBandwidth.unit.prefix;
|
||||
memBwRateUnitBase[subCluster.name] = subCluster.memoryBandwidth.unit.base;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
updateTopUserConfiguration(topUserSelection.key);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
updateTopProjectConfiguration(topProjectSelection.key);
|
||||
});
|
||||
|
||||
/* Const Functions */
|
||||
const sumUp = (data, subcluster, metric) =>
|
||||
data.reduce(
|
||||
(sum, node) =>
|
||||
node.subCluster == subcluster
|
||||
? sum +
|
||||
(node.metrics
|
||||
.find((m) => m.name == metric)
|
||||
?.metric.series.reduce(
|
||||
(sum, series) => sum + series.data[series.data.length - 1],
|
||||
0,
|
||||
) || 0)
|
||||
: sum,
|
||||
0,
|
||||
);
|
||||
|
||||
const updateConfigurationMutation = ({ name, value }) => {
|
||||
return mutationStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
mutation ($name: String!, $value: String!) {
|
||||
updateConfiguration(name: $name, value: $value)
|
||||
}
|
||||
`,
|
||||
variables: { name, value },
|
||||
});
|
||||
};
|
||||
|
||||
/* Functions */
|
||||
function updateTopUserConfiguration(select) {
|
||||
if (ccconfig[`status_view_selectedTopUserCategory:${cluster}`] != select) {
|
||||
updateConfigurationMutation({
|
||||
name: `status_view_selectedTopUserCategory:${cluster}`,
|
||||
value: JSON.stringify(select),
|
||||
}).subscribe((res) => {
|
||||
if (res.fetching === false && res.error) {
|
||||
throw res.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateTopProjectConfiguration(select) {
|
||||
if (
|
||||
ccconfig[`status_view_selectedTopProjectCategory:${cluster}`] != select
|
||||
) {
|
||||
updateConfigurationMutation({
|
||||
name: `status_view_selectedTopProjectCategory:${cluster}`,
|
||||
value: JSON.stringify(select),
|
||||
}).subscribe((res) => {
|
||||
if (res.fetching === false && res.error) {
|
||||
throw res.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Loading indicator & Refresh -->
|
||||
|
||||
<Row cols={{ lg: 3, md: 3, sm: 1 }}>
|
||||
<Col style="">
|
||||
<h4 class="mb-0">Current utilization of cluster "{cluster}"</h4>
|
||||
</Col>
|
||||
<Col class="mt-2 mt-md-0 text-md-end">
|
||||
<Button
|
||||
outline
|
||||
color="secondary"
|
||||
onclick={() => (isHistogramSelectionOpen = true)}
|
||||
>
|
||||
<Icon name="bar-chart-line" /> Select Histograms
|
||||
</Button>
|
||||
</Col>
|
||||
<Col class="mt-2 mt-md-0">
|
||||
<Refresher
|
||||
initially={120}
|
||||
onRefresh={() => {
|
||||
from = new Date(Date.now() - 5 * 60 * 1000);
|
||||
to = new Date(Date.now());
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row cols={1} class="text-center mt-3">
|
||||
<Row cols={1} class="mb-2">
|
||||
<Col>
|
||||
{#if $initq.fetching || $mainQuery.fetching}
|
||||
<Spinner />
|
||||
{:else if $initq.error}
|
||||
<Card body color="danger">{$initq.error.message}</Card>
|
||||
{:else}
|
||||
<!-- ... -->
|
||||
{/if}
|
||||
<h3 class="mb-0">Current Status of Cluster "{presetCluster.charAt(0).toUpperCase() + presetCluster.slice(1)}"</h3>
|
||||
</Col>
|
||||
</Row>
|
||||
{#if $mainQuery.error}
|
||||
<Row cols={1}>
|
||||
<Col>
|
||||
<Card body color="danger">{$mainQuery.error.message}</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
|
||||
<hr />
|
||||
<Card class="overflow-auto" style="height: auto;">
|
||||
<TabContent>
|
||||
<TabPane tabId="status-dash" tab="Status" active>
|
||||
<CardBody>
|
||||
<StatusDash {presetCluster} {useCbColors} useAltColors></StatusDash>
|
||||
</CardBody>
|
||||
</TabPane>
|
||||
|
||||
<!-- Gauges & Roofline per Subcluster-->
|
||||
|
||||
{#if $initq.data && $mainQuery.data}
|
||||
{#each $initq.data.clusters.find((c) => c.name == cluster).subClusters as subCluster, i}
|
||||
<Row cols={{ lg: 2, md: 1 , sm: 1}} class="mb-3 justify-content-center">
|
||||
<Col class="px-3">
|
||||
<Card class="h-auto mt-1">
|
||||
<CardHeader>
|
||||
<CardTitle class="mb-0">SubCluster "{subCluster.name}"</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Table borderless>
|
||||
<tr class="py-2">
|
||||
<th scope="col">Allocated Nodes</th>
|
||||
<td style="min-width: 100px;"
|
||||
><div class="col">
|
||||
<Progress
|
||||
value={allocatedNodes[subCluster.name]}
|
||||
max={subCluster.numberOfNodes}
|
||||
/>
|
||||
</div></td
|
||||
>
|
||||
<td
|
||||
>{allocatedNodes[subCluster.name]} / {subCluster.numberOfNodes}
|
||||
Nodes</td
|
||||
>
|
||||
</tr>
|
||||
<tr class="py-2">
|
||||
<th scope="col"
|
||||
>Flop Rate (Any) <Icon
|
||||
name="info-circle"
|
||||
class="p-1"
|
||||
style="cursor: help;"
|
||||
title="Flops[Any] = (Flops[Double] x 2) + Flops[Single]"
|
||||
/></th
|
||||
>
|
||||
<td style="min-width: 100px;"
|
||||
><div class="col">
|
||||
<Progress
|
||||
value={flopRate[subCluster.name]}
|
||||
max={subCluster.flopRateSimd.value *
|
||||
subCluster.numberOfNodes}
|
||||
/>
|
||||
</div></td
|
||||
>
|
||||
<td>
|
||||
{scaleNumbers(
|
||||
flopRate[subCluster.name],
|
||||
subCluster.flopRateSimd.value * subCluster.numberOfNodes,
|
||||
flopRateUnitPrefix[subCluster.name],
|
||||
)}{flopRateUnitBase[subCluster.name]} [Max]
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="py-2">
|
||||
<th scope="col">MemBw Rate</th>
|
||||
<td style="min-width: 100px;"
|
||||
><div class="col">
|
||||
<Progress
|
||||
value={memBwRate[subCluster.name]}
|
||||
max={subCluster.memoryBandwidth.value *
|
||||
subCluster.numberOfNodes}
|
||||
/>
|
||||
</div></td
|
||||
>
|
||||
<td>
|
||||
{scaleNumbers(
|
||||
memBwRate[subCluster.name],
|
||||
subCluster.memoryBandwidth.value * subCluster.numberOfNodes,
|
||||
memBwRateUnitPrefix[subCluster.name],
|
||||
)}{memBwRateUnitBase[subCluster.name]} [Max]
|
||||
</td>
|
||||
</tr>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col class="px-3 mt-2 mt-lg-0">
|
||||
<div bind:clientWidth={plotWidths[i]}>
|
||||
{#key $mainQuery.data.nodeMetrics}
|
||||
<Roofline
|
||||
allowSizeChange
|
||||
width={plotWidths[i] - 10}
|
||||
height={300}
|
||||
subCluster={subCluster}
|
||||
data={transformPerNodeDataForRoofline(
|
||||
$mainQuery.data.nodeMetrics.filter(
|
||||
(data) => data.subCluster == subCluster.name,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
{/each}
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- User and Project Stats as Pie-Charts -->
|
||||
|
||||
<Row cols={{ lg: 4, md: 2, sm: 1 }}>
|
||||
<Col class="p-2">
|
||||
<div bind:clientWidth={colWidth}>
|
||||
<h4 class="text-center">
|
||||
Top Users on {cluster.charAt(0).toUpperCase() + cluster.slice(1)}
|
||||
</h4>
|
||||
{#key $topUserQuery.data}
|
||||
{#if $topUserQuery.fetching}
|
||||
<Spinner />
|
||||
{:else if $topUserQuery.error}
|
||||
<Card body color="danger">{$topUserQuery.error.message}</Card>
|
||||
{:else}
|
||||
<Pie
|
||||
canvasId="hpcpie-users"
|
||||
size={colWidth}
|
||||
sliceLabel={topUserSelection.label}
|
||||
quantities={$topUserQuery.data.topUser.map(
|
||||
(tu) => tu[topUserSelection.key],
|
||||
)}
|
||||
entities={$topUserQuery.data.topUser.map((tu) => scrambleNames ? scramble(tu.id) : tu.id)}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</Col>
|
||||
<Col class="px-4 py-2">
|
||||
{#key $topUserQuery.data}
|
||||
{#if $topUserQuery.fetching}
|
||||
<Spinner />
|
||||
{:else if $topUserQuery.error}
|
||||
<Card body color="danger">{$topUserQuery.error.message}</Card>
|
||||
{:else}
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th>Legend</th>
|
||||
<th>User Name</th>
|
||||
<th
|
||||
>Number of
|
||||
<select class="p-0" bind:value={topUserSelection}>
|
||||
{#each topOptions as option}
|
||||
<option value={option}>
|
||||
{option.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</th>
|
||||
</tr>
|
||||
{#each $topUserQuery.data.topUser as tu, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {colors[i]};" /></td>
|
||||
<th scope="col" id="topName-{tu.id}"
|
||||
><a
|
||||
href="/monitoring/user/{tu.id}?cluster={cluster}&state=running"
|
||||
>{scrambleNames ? scramble(tu.id) : tu.id}</a
|
||||
></th
|
||||
>
|
||||
{#if tu?.name}
|
||||
<Tooltip
|
||||
target={`topName-${tu.id}`}
|
||||
placement="left"
|
||||
>{scrambleNames ? scramble(tu.name) : tu.name}</Tooltip
|
||||
>
|
||||
{/if}
|
||||
<td>{tu[topUserSelection.key]}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
{/if}
|
||||
{/key}
|
||||
</Col>
|
||||
<Col class="p-2">
|
||||
<h4 class="text-center">
|
||||
Top Projects on {cluster.charAt(0).toUpperCase() + cluster.slice(1)}
|
||||
</h4>
|
||||
{#key $topProjectQuery.data}
|
||||
{#if $topProjectQuery.fetching}
|
||||
<Spinner />
|
||||
{:else if $topProjectQuery.error}
|
||||
<Card body color="danger">{$topProjectQuery.error.message}</Card>
|
||||
{:else}
|
||||
<Pie
|
||||
canvasId="hpcpie-projects"
|
||||
size={colWidth}
|
||||
sliceLabel={topProjectSelection.label}
|
||||
quantities={$topProjectQuery.data.topProjects.map(
|
||||
(tp) => tp[topProjectSelection.key],
|
||||
)}
|
||||
entities={$topProjectQuery.data.topProjects.map((tp) => scrambleNames ? scramble(tp.id) : tp.id)}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
</Col>
|
||||
<Col class="px-4 py-2">
|
||||
{#key $topProjectQuery.data}
|
||||
{#if $topProjectQuery.fetching}
|
||||
<Spinner />
|
||||
{:else if $topProjectQuery.error}
|
||||
<Card body color="danger">{$topProjectQuery.error.message}</Card>
|
||||
{:else}
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th>Legend</th>
|
||||
<th>Project Code</th>
|
||||
<th
|
||||
>Number of
|
||||
<select class="p-0" bind:value={topProjectSelection}>
|
||||
{#each topOptions as option}
|
||||
<option value={option}>
|
||||
{option.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</th>
|
||||
</tr>
|
||||
{#each $topProjectQuery.data.topProjects as tp, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {colors[i]};" /></td>
|
||||
<th scope="col"
|
||||
><a
|
||||
href="/monitoring/jobs/?cluster={cluster}&state=running&project={tp.id}&projectMatch=eq"
|
||||
>{scrambleNames ? scramble(tp.id) : tp.id}</a
|
||||
></th
|
||||
>
|
||||
<td>{tp[topProjectSelection.key]}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
{/if}
|
||||
{/key}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
<!-- Static Stats as Histograms : Running Duration && Allocated Hardware Counts-->
|
||||
|
||||
<Row cols={{ lg: 2, md: 1 }}>
|
||||
<Col class="p-2">
|
||||
{#key $mainQuery.data.stats}
|
||||
<Histogram
|
||||
data={convert2uplot($mainQuery.data.stats[0].histDuration)}
|
||||
title="Duration Distribution"
|
||||
xlabel="Current Job Runtimes"
|
||||
xunit="Runtime"
|
||||
ylabel="Number of Jobs"
|
||||
yunit="Jobs"
|
||||
usesBins
|
||||
xtime
|
||||
/>
|
||||
{/key}
|
||||
</Col>
|
||||
<Col class="p-2">
|
||||
{#key $mainQuery.data.stats}
|
||||
<Histogram
|
||||
data={convert2uplot($mainQuery.data.stats[0].histNumNodes)}
|
||||
title="Number of Nodes Distribution"
|
||||
xlabel="Allocated Nodes"
|
||||
xunit="Nodes"
|
||||
ylabel="Number of Jobs"
|
||||
yunit="Jobs"
|
||||
/>
|
||||
{/key}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row cols={{ lg: 2, md: 1 }}>
|
||||
<Col class="p-2">
|
||||
{#key $mainQuery.data.stats}
|
||||
<Histogram
|
||||
data={convert2uplot($mainQuery.data.stats[0].histNumCores)}
|
||||
title="Number of Cores Distribution"
|
||||
xlabel="Allocated Cores"
|
||||
xunit="Cores"
|
||||
ylabel="Number of Jobs"
|
||||
yunit="Jobs"
|
||||
/>
|
||||
{/key}
|
||||
</Col>
|
||||
<Col class="p-2">
|
||||
{#key $mainQuery.data.stats}
|
||||
<Histogram
|
||||
data={convert2uplot($mainQuery.data.stats[0].histNumAccs)}
|
||||
title="Number of Accelerators Distribution"
|
||||
xlabel="Allocated Accs"
|
||||
xunit="Accs"
|
||||
ylabel="Number of Jobs"
|
||||
yunit="Jobs"
|
||||
/>
|
||||
{/key}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<hr class="my-2" />
|
||||
|
||||
<!-- Selectable Stats as Histograms : Average Values of Running Jobs -->
|
||||
|
||||
{#if selectedHistograms}
|
||||
<!-- Note: Ignore '#snippet' Error in IDE -->
|
||||
{#snippet gridContent(item)}
|
||||
<Histogram
|
||||
data={convert2uplot(item.data)}
|
||||
title="Distribution of '{item.metric}' averages"
|
||||
xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`}
|
||||
xunit={item.unit}
|
||||
ylabel="Number of Jobs"
|
||||
yunit="Jobs"
|
||||
usesBins
|
||||
/>
|
||||
{/snippet}
|
||||
<TabPane tabId="usage-dash" tab="Usage">
|
||||
<CardBody>
|
||||
<UsageDash {presetCluster} {useCbColors}></UsageDash>
|
||||
</CardBody>
|
||||
</TabPane>
|
||||
|
||||
{#key $mainQuery.data.stats[0].histMetrics}
|
||||
<PlotGrid
|
||||
items={$mainQuery.data.stats[0].histMetrics}
|
||||
itemsPerRow={2}
|
||||
{gridContent}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<HistogramSelection
|
||||
{cluster}
|
||||
bind:isOpen={isHistogramSelectionOpen}
|
||||
presetSelectedHistograms={selectedHistograms}
|
||||
applyChange={(newSelection) => {
|
||||
selectedHistograms = [...newSelection];
|
||||
}}
|
||||
/>
|
||||
<TabPane tabId="metric-dash" tab="Statistics">
|
||||
<CardBody>
|
||||
<StatisticsDash {presetCluster} {useCbColors}></StatisticsDash>
|
||||
</CardBody>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</Card>
|
@@ -404,6 +404,7 @@
|
||||
cluster={selectedCluster}
|
||||
bind:isOpen={isHistogramSelectionOpen}
|
||||
presetSelectedHistograms={selectedHistograms}
|
||||
configName="user_view_histogramMetrics"
|
||||
applyChange={(newSelection) => {
|
||||
selectedHistogramsBuffer[selectedCluster || 'all'] = [...newSelection];
|
||||
}}
|
||||
|
@@ -26,7 +26,7 @@
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
matchedCompareJobs = $bindable(0),
|
||||
metrics = ccconfig?.plot_list_selectedMetrics,
|
||||
metrics = getContext("cc-config")?.plot_list_selectedMetrics,
|
||||
filterBuffer = [],
|
||||
} = $props();
|
||||
|
||||
|
@@ -44,7 +44,7 @@
|
||||
|
||||
/* Const Init */
|
||||
const clusterCockpitConfig = getContext("cc-config");
|
||||
const lineWidth = clusterCockpitConfig.plot_general_lineWidth / window.devicePixelRatio;
|
||||
const lineWidth = clusterCockpitConfig?.plot_general_lineWidth / window.devicePixelRatio || 2;
|
||||
const cbmode = clusterCockpitConfig?.plot_general_colorblindMode || false;
|
||||
|
||||
// UPLOT SERIES INIT //
|
||||
|
@@ -14,28 +14,59 @@
|
||||
-->
|
||||
|
||||
<script 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)'
|
||||
];
|
||||
export const colors = {
|
||||
// https://www.learnui.design/tools/data-color-picker.html#divergent: 11, Shallow Green-Red
|
||||
default: [
|
||||
"#00876c",
|
||||
"#449c6e",
|
||||
"#70af6f",
|
||||
"#9bc271",
|
||||
"#c8d377",
|
||||
"#f7e382",
|
||||
"#f6c468",
|
||||
"#f3a457",
|
||||
"#ed834e",
|
||||
"#e3614d",
|
||||
"#d43d51",
|
||||
],
|
||||
// https://www.learnui.design/tools/data-color-picker.html#palette: 12, Colorwheel-Like
|
||||
alternative: [
|
||||
"#0022bb",
|
||||
"#ba0098",
|
||||
"#fa0066",
|
||||
"#ff6234",
|
||||
"#ffae00",
|
||||
"#b1af00",
|
||||
"#67a630",
|
||||
"#009753",
|
||||
"#00836c",
|
||||
"#006d77",
|
||||
"#005671",
|
||||
"#003f5c",
|
||||
],
|
||||
// http://tsitsul.in/blog/coloropt/ : 12 colors normal
|
||||
colorblind: [
|
||||
'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>
|
||||
/* Ignore Double Script Section Error in IDE */
|
||||
// Ignore VSC IDE "One Instance Level Script" Error
|
||||
import { onMount, getContext } from "svelte";
|
||||
import Chart from 'chart.js/auto';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
@@ -45,21 +76,11 @@
|
||||
quantities,
|
||||
entities,
|
||||
displayLegend = false,
|
||||
useAltColors = false,
|
||||
} = $props();
|
||||
|
||||
/* Const Init */
|
||||
const data = {
|
||||
labels: entities,
|
||||
datasets: [
|
||||
{
|
||||
label: sliceLabel,
|
||||
data: quantities,
|
||||
fill: 1,
|
||||
backgroundColor: colors.slice(0, quantities.length)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const useCbColors = getContext("cc-config")?.plot_general_colorblindMode || false
|
||||
const options = {
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
@@ -70,6 +91,31 @@
|
||||
}
|
||||
};
|
||||
|
||||
/* Derived */
|
||||
const colorPalette = $derived.by(() => {
|
||||
let c;
|
||||
if (useCbColors) {
|
||||
c = [...colors['colorblind']];
|
||||
} else if (useAltColors) {
|
||||
c = [...colors['alternative']];
|
||||
} else {
|
||||
c = [...colors['default']];
|
||||
}
|
||||
return c.slice(0, quantities.length);
|
||||
})
|
||||
|
||||
const data = $derived({
|
||||
labels: entities,
|
||||
datasets: [
|
||||
{
|
||||
label: sliceLabel,
|
||||
data: quantities,
|
||||
fill: 1,
|
||||
backgroundColor: colorPalette,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
/* On Mount */
|
||||
onMount(() => {
|
||||
new Chart(
|
||||
@@ -84,7 +130,7 @@
|
||||
</script>
|
||||
|
||||
<!-- <div style="width: 500px;"><canvas id="dimensions"></canvas></div><br/> -->
|
||||
<div class="chart-container" style="--container-width: {size}; --container-height: {size}">
|
||||
<div class="chart-container" style="--container-width: {size}px; --container-height: {size}px">
|
||||
<canvas id={canvasId}></canvas>
|
||||
</div>
|
||||
|
||||
|
@@ -3,7 +3,6 @@
|
||||
|
||||
Properties:
|
||||
- `data [null, [], []]`: Roofline Data Structure, see below for details [Default: null]
|
||||
- `renderTime Bool?`: If time information should be rendered as colored dots [Default: false]
|
||||
- `allowSizeChange Bool?`: If dimensions of rendered plot can change [Default: false]
|
||||
- `subCluster GraphQL.SubCluster?`: SubCluster Object; contains required topology information [Default: null]
|
||||
- `width Number?`: Plot width (reactively adaptive) [Default: 600]
|
||||
@@ -21,19 +20,22 @@
|
||||
- `data[2] = [0.1, 0.15, 0.2, ...]`
|
||||
- Color Code: Time Information (Floats from 0 to 1) (Optional)
|
||||
-->
|
||||
|
||||
<script>
|
||||
import uPlot from "uplot";
|
||||
import { formatNumber } from "../units.js";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { Card } from "@sveltestrap/sveltestrap";
|
||||
import { roundTwoDigits } from "../units.js";
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
data = null,
|
||||
renderTime = false,
|
||||
allowSizeChange = false,
|
||||
roofData = null,
|
||||
jobsData = null,
|
||||
nodesData = null,
|
||||
cluster = null,
|
||||
subCluster = null,
|
||||
allowSizeChange = false,
|
||||
useColors = true,
|
||||
width = 600,
|
||||
height = 380,
|
||||
} = $props();
|
||||
@@ -54,8 +56,27 @@
|
||||
if (allowSizeChange) sizeChanged(width, height);
|
||||
});
|
||||
|
||||
// Copied Example Vars for Uplot Bubble
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/isPointInPath
|
||||
let qt;
|
||||
let hRect;
|
||||
let pxRatio;
|
||||
function setPxRatio() {
|
||||
pxRatio = uPlot.pxRatio;
|
||||
}
|
||||
setPxRatio();
|
||||
window.addEventListener('dppxchange', setPxRatio);
|
||||
// let minSize = 6;
|
||||
let maxSize = 60;
|
||||
// let maxArea = Math.PI * (maxSize / 2) ** 2;
|
||||
// let minArea = Math.PI * (minSize / 2) ** 2;
|
||||
|
||||
/* Functions */
|
||||
// Helper
|
||||
function pointWithin(px, py, rlft, rtop, rrgt, rbtm) {
|
||||
return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm;
|
||||
}
|
||||
|
||||
function getGradientR(x) {
|
||||
if (x < 0.5) return 0;
|
||||
if (x > 0.75) return 255;
|
||||
@@ -74,8 +95,9 @@
|
||||
x = 1.0 - (x - 0.25) * 4.0;
|
||||
return Math.floor(x * 255.0);
|
||||
}
|
||||
function getRGB(c) {
|
||||
return `rgb(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)})`;
|
||||
function getRGB(c, transparent = false) {
|
||||
if (transparent) return `rgba(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)}, 0.5)`;
|
||||
else return `rgb(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)})`;
|
||||
}
|
||||
function nearestThousand(num) {
|
||||
return Math.ceil(num / 1000) * 1000;
|
||||
@@ -89,126 +111,492 @@
|
||||
};
|
||||
}
|
||||
|
||||
// Dot Renderers
|
||||
const drawColorPoints = (u, seriesIdx, idx0, idx1) => {
|
||||
const size = 5 * devicePixelRatio;
|
||||
uPlot.orient(
|
||||
u,
|
||||
seriesIdx,
|
||||
(
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
moveTo,
|
||||
lineTo,
|
||||
rect,
|
||||
arc,
|
||||
) => {
|
||||
// quadratic scaling (px area)
|
||||
// function getSize(value, minValue, maxValue) {
|
||||
// let pct = value / maxValue;
|
||||
// // clamp to min area
|
||||
// //let area = Math.max(maxArea * pct, minArea);
|
||||
// let area = maxArea * pct;
|
||||
// return Math.sqrt(area / Math.PI) * 2;
|
||||
// }
|
||||
|
||||
// function getSizeMinMax(u) {
|
||||
// let minValue = Infinity;
|
||||
// let maxValue = -Infinity;
|
||||
// for (let i = 1; i < u.series.length; i++) {
|
||||
// let sizeData = u.data[i][2];
|
||||
// for (let j = 0; j < sizeData.length; j++) {
|
||||
// minValue = Math.min(minValue, sizeData[j]);
|
||||
// maxValue = Math.max(maxValue, sizeData[j]);
|
||||
// }
|
||||
// }
|
||||
// return [minValue, maxValue];
|
||||
// }
|
||||
|
||||
// Quadtree Object (TODO: Split and Import)
|
||||
class Quadtree {
|
||||
constructor (x, y, w, h, l) {
|
||||
let t = this;
|
||||
t.x = x;
|
||||
t.y = y;
|
||||
t.w = w;
|
||||
t.h = h;
|
||||
t.l = l || 0;
|
||||
t.o = [];
|
||||
t.q = null;
|
||||
t.MAX_OBJECTS = 10;
|
||||
t.MAX_LEVELS = 4;
|
||||
};
|
||||
|
||||
get quadtree() {
|
||||
return "Implement me!";
|
||||
}
|
||||
|
||||
split() {
|
||||
let t = this,
|
||||
x = t.x,
|
||||
y = t.y,
|
||||
w = t.w / 2,
|
||||
h = t.h / 2,
|
||||
l = t.l + 1;
|
||||
|
||||
t.q = [
|
||||
// top right
|
||||
new Quadtree(x + w, y, w, h, l),
|
||||
// top left
|
||||
new Quadtree(x, y, w, h, l),
|
||||
// bottom left
|
||||
new Quadtree(x, y + h, w, h, l),
|
||||
// bottom right
|
||||
new Quadtree(x + w, y + h, w, h, l),
|
||||
];
|
||||
};
|
||||
|
||||
quads(x, y, w, h, cb) {
|
||||
let t = this,
|
||||
q = t.q,
|
||||
hzMid = t.x + t.w / 2,
|
||||
vtMid = t.y + t.h / 2,
|
||||
startIsNorth = y < vtMid,
|
||||
startIsWest = x < hzMid,
|
||||
endIsEast = x + w > hzMid,
|
||||
endIsSouth = y + h > vtMid;
|
||||
|
||||
// top-right quad
|
||||
startIsNorth && endIsEast && cb(q[0]);
|
||||
// top-left quad
|
||||
startIsWest && startIsNorth && cb(q[1]);
|
||||
// bottom-left quad
|
||||
startIsWest && endIsSouth && cb(q[2]);
|
||||
// bottom-right quad
|
||||
endIsEast && endIsSouth && cb(q[3]);
|
||||
};
|
||||
|
||||
add(o) {
|
||||
let t = this;
|
||||
|
||||
if (t.q != null) {
|
||||
t.quads(o.x, o.y, o.w, o.h, q => {
|
||||
q.add(o);
|
||||
});
|
||||
}
|
||||
else {
|
||||
let os = t.o;
|
||||
|
||||
os.push(o);
|
||||
|
||||
if (os.length > t.MAX_OBJECTS && t.l < t.MAX_LEVELS) {
|
||||
t.split();
|
||||
|
||||
for (let i = 0; i < os.length; i++) {
|
||||
let oi = os[i];
|
||||
|
||||
t.quads(oi.x, oi.y, oi.w, oi.h, q => {
|
||||
q.add(oi);
|
||||
});
|
||||
}
|
||||
|
||||
t.o.length = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
get(x, y, w, h, cb) {
|
||||
let t = this;
|
||||
let os = t.o;
|
||||
|
||||
for (let i = 0; i < os.length; i++)
|
||||
cb(os[i]);
|
||||
|
||||
if (t.q != null) {
|
||||
t.quads(x, y, w, h, q => {
|
||||
q.get(x, y, w, h, cb);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.o.length = 0;
|
||||
this.q = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Dot Renderer
|
||||
const makeDrawPoints = (opts) => {
|
||||
let {/*size, disp,*/ transparentFill, each = () => {}} = opts;
|
||||
const sizeBase = 6 * pxRatio;
|
||||
|
||||
return (u, seriesIdx, idx0, idx1) => {
|
||||
uPlot.orient(u, seriesIdx, (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect, arc) => {
|
||||
let d = u.data[seriesIdx];
|
||||
let strokeWidth = 1;
|
||||
let deg360 = 2 * Math.PI;
|
||||
/* Alt.: Sizes based on other Data Rows */
|
||||
// let sizes = disp.size.values(u, seriesIdx, idx0, idx1);
|
||||
|
||||
u.ctx.save();
|
||||
u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
u.ctx.clip();
|
||||
u.ctx.lineWidth = strokeWidth;
|
||||
|
||||
// todo: this depends on direction & orientation
|
||||
// todo: calc once per redraw, not per path
|
||||
let filtLft = u.posToVal(-maxSize / 2, scaleX.key);
|
||||
let filtRgt = u.posToVal(u.bbox.width / pxRatio + maxSize / 2, scaleX.key);
|
||||
let filtBtm = u.posToVal(u.bbox.height / pxRatio + maxSize / 2, scaleY.key);
|
||||
let filtTop = u.posToVal(-maxSize / 2, scaleY.key);
|
||||
|
||||
for (let i = 0; i < d[0].length; i++) {
|
||||
let p = new Path2D();
|
||||
if (useColors) {
|
||||
u.ctx.strokeStyle = "rgb(0, 0, 0)";
|
||||
// Jobs: Color based on Duration
|
||||
if (jobsData) {
|
||||
//u.ctx.strokeStyle = getRGB(u.data[2][i]);
|
||||
u.ctx.fillStyle = getRGB(u.data[2][i], transparentFill);
|
||||
// Nodes: Color based on Idle vs. Allocated
|
||||
} else if (nodesData) {
|
||||
// console.log('In Plot Handler NodesData', nodesData)
|
||||
if (nodesData[i]?.nodeState == "idle") {
|
||||
//u.ctx.strokeStyle = "rgb(0, 0, 255)";
|
||||
u.ctx.fillStyle = "rgba(0, 0, 255, 0.5)";
|
||||
} else if (nodesData[i]?.nodeState == "allocated") {
|
||||
//u.ctx.strokeStyle = "rgb(0, 255, 0)";
|
||||
u.ctx.fillStyle = "rgba(0, 255, 0, 0.5)";
|
||||
} else if (nodesData[i]?.nodeState == "notindb") {
|
||||
//u.ctx.strokeStyle = "rgb(0, 0, 0)";
|
||||
u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
||||
} else { // Fallback: All other DEFINED states
|
||||
//u.ctx.strokeStyle = "rgb(255, 0, 0)";
|
||||
u.ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No Colors: Use Black
|
||||
u.ctx.strokeStyle = "rgb(0, 0, 0)";
|
||||
u.ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
|
||||
}
|
||||
|
||||
// Get Values
|
||||
let xVal = d[0][i];
|
||||
let yVal = d[1][i];
|
||||
u.ctx.strokeStyle = getRGB(u.data[2][i]);
|
||||
u.ctx.fillStyle = getRGB(u.data[2][i]);
|
||||
if (
|
||||
xVal >= scaleX.min &&
|
||||
xVal <= scaleX.max &&
|
||||
yVal >= scaleY.min &&
|
||||
yVal <= scaleY.max
|
||||
) {
|
||||
|
||||
// Calc Size; Alt.: size = sizes[i] * pxRatio
|
||||
let size = 1;
|
||||
|
||||
// Jobs: Size based on Resourcecount
|
||||
if (jobsData) {
|
||||
const scaling = jobsData[i].numNodes > 12
|
||||
? 24 // Capped Dot Size
|
||||
: jobsData[i].numNodes > 1
|
||||
? jobsData[i].numNodes * 2 // MultiNode Scaling
|
||||
: jobsData[i]?.numAcc ? jobsData[i].numAcc : jobsData[i].numNodes * 2 // Single Node or Scale by Accs
|
||||
size = sizeBase + scaling
|
||||
// Nodes: Size based on Jobcount
|
||||
} else if (nodesData) {
|
||||
size = sizeBase + (nodesData[i]?.numJobs * 1.5) // Max Jobs Scale: 8 * 1.5 = 12
|
||||
};
|
||||
|
||||
if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) {
|
||||
let cx = valToPosX(xVal, scaleX, xDim, xOff);
|
||||
let cy = valToPosY(yVal, scaleY, yDim, yOff);
|
||||
|
||||
p.moveTo(cx + size / 2, cy);
|
||||
arc(p, cx, cy, size / 2, 0, deg360);
|
||||
u.ctx.moveTo(cx + size/2, cy);
|
||||
u.ctx.beginPath();
|
||||
u.ctx.arc(cx, cy, size/2, 0, deg360);
|
||||
u.ctx.fill();
|
||||
u.ctx.stroke();
|
||||
|
||||
each(u, seriesIdx, i,
|
||||
cx - size/2 - strokeWidth/2,
|
||||
cy - size/2 - strokeWidth/2,
|
||||
size + strokeWidth,
|
||||
size + strokeWidth
|
||||
);
|
||||
}
|
||||
u.ctx.fill(p);
|
||||
}
|
||||
},
|
||||
);
|
||||
return null;
|
||||
u.ctx.restore();
|
||||
});
|
||||
return null;
|
||||
};
|
||||
};
|
||||
|
||||
const drawPoints = (u, seriesIdx, idx0, idx1) => {
|
||||
const size = 5 * devicePixelRatio;
|
||||
uPlot.orient(
|
||||
u,
|
||||
seriesIdx,
|
||||
(
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
moveTo,
|
||||
lineTo,
|
||||
rect,
|
||||
arc,
|
||||
) => {
|
||||
let d = u.data[seriesIdx];
|
||||
u.ctx.strokeStyle = getRGB(0);
|
||||
u.ctx.fillStyle = getRGB(0);
|
||||
let deg360 = 2 * Math.PI;
|
||||
let p = new Path2D();
|
||||
for (let i = 0; i < d[0].length; i++) {
|
||||
let xVal = d[0][i];
|
||||
let yVal = d[1][i];
|
||||
if (
|
||||
xVal >= scaleX.min &&
|
||||
xVal <= scaleX.max &&
|
||||
yVal >= scaleY.min &&
|
||||
yVal <= scaleY.max
|
||||
) {
|
||||
let cx = valToPosX(xVal, scaleX, xDim, xOff);
|
||||
let cy = valToPosY(yVal, scaleY, yDim, yOff);
|
||||
p.moveTo(cx + size / 2, cy);
|
||||
arc(p, cx, cy, size / 2, 0, deg360);
|
||||
let drawPoints = makeDrawPoints({
|
||||
// disp: {
|
||||
// size: {
|
||||
// // unit: 3, // raw CSS pixels
|
||||
// // discr: true,
|
||||
// values: (u, seriesIdx, idx0, idx1) => {
|
||||
// /* Func to get sizes from additional subSeries [series][2...x] ([0,1] is [x,y]) */
|
||||
// // TODO: only run once per setData() call
|
||||
// let [minValue, maxValue] = getSizeMinMax(u);
|
||||
// return u.data[seriesIdx][2].map(v => getSize(v, minValue, maxValue));
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
transparentFill: true,
|
||||
each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => {
|
||||
// we get back raw canvas coords (included axes & padding). translate to the plotting area origin
|
||||
lft -= u.bbox.left;
|
||||
top -= u.bbox.top;
|
||||
qt.add({x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx});
|
||||
},
|
||||
});
|
||||
|
||||
const legendValues = (u, seriesIdx, dataIdx) => {
|
||||
// when data null, it's initial schema probe (also u.status == 0)
|
||||
if (u.data == null || dataIdx == null || hRect == null || hRect.sidx != seriesIdx) {
|
||||
return {
|
||||
"Intensity [FLOPS/Byte]": '-',
|
||||
"":'',
|
||||
"Performace [GFLOPS]": '-'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"Intensity [FLOPS/Byte]": roundTwoDigits(u.data[seriesIdx][0][dataIdx]),
|
||||
"":'',
|
||||
"Performace [GFLOPS]": roundTwoDigits(u.data[seriesIdx][1][dataIdx]),
|
||||
};
|
||||
};
|
||||
|
||||
// Tooltip Plugin
|
||||
function tooltipPlugin({onclick, getLegendData, shiftX = 10, shiftY = 10}) {
|
||||
let tooltipLeftOffset = 0;
|
||||
let tooltipTopOffset = 0;
|
||||
|
||||
const tooltip = document.createElement("div");
|
||||
|
||||
// Build Manual Class By Styles
|
||||
tooltip.style.fontSize = "10pt";
|
||||
tooltip.style.position = "absolute";
|
||||
tooltip.style.background = "#fcfcfc";
|
||||
tooltip.style.display = "none";
|
||||
tooltip.style.border = "2px solid black";
|
||||
tooltip.style.padding = "4px";
|
||||
tooltip.style.pointerEvents = "none";
|
||||
tooltip.style.zIndex = "100";
|
||||
tooltip.style.whiteSpace = "pre";
|
||||
tooltip.style.fontFamily = "monospace";
|
||||
|
||||
const tipSeriesIdx = 1; // Scatter: Series IDX is always 1
|
||||
let tipDataIdx = null;
|
||||
|
||||
// const fmtDate = uPlot.fmtDate("{M}/{D}/{YY} {h}:{mm}:{ss} {AA}");
|
||||
let over;
|
||||
let tooltipVisible = false;
|
||||
|
||||
function showTooltip() {
|
||||
if (!tooltipVisible) {
|
||||
tooltip.style.display = "block";
|
||||
over.style.cursor = "pointer";
|
||||
tooltipVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
if (tooltipVisible) {
|
||||
tooltip.style.display = "none";
|
||||
over.style.cursor = null;
|
||||
tooltipVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setTooltip(u, i) {
|
||||
showTooltip();
|
||||
|
||||
let top = u.valToPos(u.data[tipSeriesIdx][1][i], 'y');
|
||||
let lft = u.valToPos(u.data[tipSeriesIdx][0][i], 'x');
|
||||
|
||||
tooltip.style.top = (tooltipTopOffset + top + shiftX) + "px";
|
||||
tooltip.style.left = (tooltipLeftOffset + lft + shiftY) + "px";
|
||||
|
||||
if (useColors) {
|
||||
// Jobs: Color based on Duration
|
||||
if (jobsData) {
|
||||
tooltip.style.borderColor = getRGB(u.data[2][i]);
|
||||
// Nodes: Color based on Idle vs. Allocated
|
||||
} else if (nodesData) {
|
||||
if (nodesData[i]?.nodeState == "idle") {
|
||||
tooltip.style.borderColor = "rgb(0, 0, 255)";
|
||||
} else if (nodesData[i]?.nodeState == "allocated") {
|
||||
tooltip.style.borderColor = "rgb(0, 255, 0)";
|
||||
} else if (nodesData[i]?.nodeState == "notindb") { // Missing from DB table
|
||||
tooltip.style.borderColor = "rgb(0, 0, 0)";
|
||||
} else { // Fallback: All other DEFINED states
|
||||
tooltip.style.borderColor = "rgb(255, 0, 0)";
|
||||
}
|
||||
}
|
||||
u.ctx.fill(p);
|
||||
},
|
||||
);
|
||||
return null;
|
||||
};
|
||||
} else {
|
||||
// No Colors: Use Black
|
||||
tooltip.style.borderColor = "rgb(0, 0, 0)";
|
||||
}
|
||||
|
||||
if (jobsData) {
|
||||
tooltip.textContent = (
|
||||
// Tooltip Content as String for Job
|
||||
`Job ID: ${getLegendData(u, i).jobId}\nRuntime: ${getLegendData(u, i).duration}\nNodes: ${getLegendData(u, i).numNodes}${getLegendData(u, i)?.numAcc?`\nAccelerators: ${getLegendData(u, i).numAcc}`:''}`
|
||||
);
|
||||
} else if (nodesData && useColors) {
|
||||
tooltip.textContent = (
|
||||
// Tooltip Content as String for Node
|
||||
`Host: ${getLegendData(u, i).nodeName}\nState: ${getLegendData(u, i).nodeState}\nJobs: ${getLegendData(u, i).numJobs}`
|
||||
);
|
||||
} else if (nodesData && !useColors) {
|
||||
tooltip.textContent = (
|
||||
// Tooltip Content as String for Node
|
||||
`Host: ${getLegendData(u, i).nodeName}\nJobs: ${getLegendData(u, i).numJobs}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
ready: [
|
||||
u => {
|
||||
over = u.over;
|
||||
tooltipLeftOffset = parseFloat(over.style.left);
|
||||
tooltipTopOffset = parseFloat(over.style.top);
|
||||
u.root.querySelector(".u-wrap").appendChild(tooltip);
|
||||
|
||||
let clientX;
|
||||
let clientY;
|
||||
|
||||
over.addEventListener("mousedown", e => {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
});
|
||||
|
||||
over.addEventListener("mouseup", e => {
|
||||
// clicked in-place
|
||||
if (e.clientX == clientX && e.clientY == clientY) {
|
||||
if (tipDataIdx != null) {
|
||||
onclick(u, tipDataIdx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
],
|
||||
setCursor: [
|
||||
u => {
|
||||
let i = u.legend.idxs[1];
|
||||
if (i != null) {
|
||||
tipDataIdx = i;
|
||||
setTooltip(u, i);
|
||||
} else {
|
||||
tipDataIdx = null;
|
||||
hideTooltip();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Main Functions
|
||||
function sizeChanged() {
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
if (uplot) uplot.destroy();
|
||||
render(data);
|
||||
render(roofData, jobsData, nodesData);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function render(plotData) {
|
||||
if (plotData) {
|
||||
function render(roofData, jobsData, nodesData) {
|
||||
let plotTitle = "CPU Roofline Diagram";
|
||||
if (jobsData) plotTitle = "Job Average Roofline Diagram";
|
||||
if (nodesData) plotTitle = "Node Average Roofline Diagram";
|
||||
|
||||
if (roofData) {
|
||||
const opts = {
|
||||
title: "CPU Roofline Diagram",
|
||||
title: plotTitle,
|
||||
mode: 2,
|
||||
width: width,
|
||||
height: height,
|
||||
legend: {
|
||||
show: false,
|
||||
show: true,
|
||||
},
|
||||
cursor: {
|
||||
dataIdx: (u, seriesIdx) => {
|
||||
if (seriesIdx == 1) {
|
||||
hRect = null;
|
||||
|
||||
let dist = Infinity;
|
||||
let area = Infinity;
|
||||
let cx = u.cursor.left * pxRatio;
|
||||
let cy = u.cursor.top * pxRatio;
|
||||
|
||||
qt.get(cx, cy, 1, 1, o => {
|
||||
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
|
||||
let ocx = o.x + o.w / 2;
|
||||
let ocy = o.y + o.h / 2;
|
||||
|
||||
let dx = ocx - cx;
|
||||
let dy = ocy - cy;
|
||||
|
||||
let d = Math.sqrt(dx ** 2 + dy ** 2);
|
||||
|
||||
// test against radius for actual hover
|
||||
if (d <= o.w / 2) {
|
||||
let a = o.w * o.h;
|
||||
|
||||
// prefer smallest
|
||||
if (a < area) {
|
||||
area = a;
|
||||
dist = d;
|
||||
hRect = o;
|
||||
}
|
||||
// only hover bbox with closest distance
|
||||
else if (a == area && d <= dist) {
|
||||
dist = d;
|
||||
hRect = o;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return hRect && seriesIdx == hRect.sidx ? hRect.didx : null;
|
||||
},
|
||||
/* Render "Fill" on Data Point Hover: Works in Example Bubble, does not work here? Guess: Interference with tooltip */
|
||||
// points: {
|
||||
// size: (u, seriesIdx) => {
|
||||
// return hRect && seriesIdx == hRect.sidx ? hRect.w / pxRatio : 0;
|
||||
// }
|
||||
// },
|
||||
/* Make all non-focused series semi-transparent: Useless unless more than one series rendered */
|
||||
// focus: {
|
||||
// prox: 1e3,
|
||||
// alpha: 0.3,
|
||||
// dist: (u, seriesIdx) => {
|
||||
// let prox = (hRect?.sidx === seriesIdx ? 0 : Infinity);
|
||||
// return prox;
|
||||
// },
|
||||
// },
|
||||
drag: { // Activates Zoom: Only one Dimension; YX Breaks Zoom Reset (Reason TBD)
|
||||
x: true,
|
||||
y: false
|
||||
},
|
||||
},
|
||||
cursor: { drag: { x: false, y: false } },
|
||||
axes: [
|
||||
{
|
||||
label: "Intensity [FLOPS/Byte]",
|
||||
@@ -228,7 +616,7 @@
|
||||
},
|
||||
y: {
|
||||
range: [
|
||||
1.0,
|
||||
0.01,
|
||||
subCluster?.flopRateSimd?.value
|
||||
? nearestThousand(subCluster.flopRateSimd.value)
|
||||
: 10000,
|
||||
@@ -237,12 +625,36 @@
|
||||
log: 10, // log exp
|
||||
},
|
||||
},
|
||||
series: [{}, { paths: renderTime ? drawColorPoints : drawPoints }],
|
||||
series: [
|
||||
null,
|
||||
{
|
||||
/* Facets: Define Purpose of Sub-Arrays in Series-Array, e.g. x, y, size, label, color, ... */
|
||||
// facets: [
|
||||
// {
|
||||
// scale: 'x',
|
||||
// auto: true,
|
||||
// },
|
||||
// {
|
||||
// scale: 'y',
|
||||
// auto: true,
|
||||
// }
|
||||
// ],
|
||||
paths: drawPoints,
|
||||
values: legendValues
|
||||
}
|
||||
],
|
||||
hooks: {
|
||||
// setSeries: [ (u, seriesIdx) => console.log('setSeries', seriesIdx) ],
|
||||
// setLegend: [ u => console.log('setLegend', u.legend.idxs) ],
|
||||
drawClear: [
|
||||
(u) => {
|
||||
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
|
||||
qt.clear();
|
||||
|
||||
// force-clear the path cache to cause drawBars() to rebuild new quadtree
|
||||
u.series.forEach((s, i) => {
|
||||
if (i > 0) s._paths = null;
|
||||
if (i > 0)
|
||||
s._paths = null;
|
||||
});
|
||||
},
|
||||
],
|
||||
@@ -334,30 +746,92 @@
|
||||
// Reset grid lineWidth
|
||||
u.ctx.lineWidth = 0.15;
|
||||
}
|
||||
if (renderTime) {
|
||||
// The Color Scale For Time Information
|
||||
const posX = u.valToPos(0.1, "x", true)
|
||||
const posXLimit = u.valToPos(100, "x", true)
|
||||
const posY = u.valToPos(14000.0, "y", true)
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText('Start', posX, posY)
|
||||
const start = posX + 10
|
||||
for (let x = start; x < posXLimit; x += 10) {
|
||||
|
||||
/* Render Scales */
|
||||
if (useColors) {
|
||||
// Jobs: The Color Scale For Time Information
|
||||
if (jobsData) {
|
||||
const posX = u.valToPos(0.1, "x", true)
|
||||
const posXLimit = u.valToPos(100, "x", true)
|
||||
const posY = u.valToPos(17500.0, "y", true)
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText('0 Hours', posX, posY)
|
||||
const start = posX + 10
|
||||
for (let x = start; x < posXLimit; x += 10) {
|
||||
let c = (x - start) / (posXLimit - start)
|
||||
u.ctx.fillStyle = getRGB(c)
|
||||
u.ctx.beginPath()
|
||||
u.ctx.arc(x, posY, 3, 0, Math.PI * 2, false)
|
||||
u.ctx.fill()
|
||||
}
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText('24 Hours', posXLimit + 55, posY)
|
||||
}
|
||||
|
||||
// Nodes: The Colors Of NodeStates
|
||||
if (nodesData) {
|
||||
const posY = u.valToPos(17500.0, "y", true)
|
||||
|
||||
const posAllocDot = u.valToPos(0.03, "x", true)
|
||||
const posAllocText = posAllocDot + 60
|
||||
const posIdleDot = u.valToPos(0.3, "x", true)
|
||||
const posIdleText = posIdleDot + 30
|
||||
const posOtherDot = u.valToPos(3, "x", true)
|
||||
const posOtherText = posOtherDot + 40
|
||||
const posMissingDot = u.valToPos(30, "x", true)
|
||||
const posMissingText = posMissingDot + 80
|
||||
|
||||
u.ctx.fillStyle = "rgb(0, 255, 0)"
|
||||
u.ctx.beginPath()
|
||||
u.ctx.arc(posAllocDot, posY, 3, 0, Math.PI * 2, false)
|
||||
u.ctx.fill()
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText('Allocated', posAllocText, posY)
|
||||
|
||||
u.ctx.fillStyle = "rgb(0, 0, 255)"
|
||||
u.ctx.beginPath()
|
||||
u.ctx.arc(posIdleDot, posY, 3, 0, Math.PI * 2, false)
|
||||
u.ctx.fill()
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText('Idle', posIdleText, posY)
|
||||
|
||||
u.ctx.fillStyle = "rgb(255, 0, 0)"
|
||||
u.ctx.beginPath()
|
||||
u.ctx.arc(posOtherDot, posY, 3, 0, Math.PI * 2, false)
|
||||
u.ctx.fill()
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText('Other', posOtherText, posY)
|
||||
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.beginPath()
|
||||
u.ctx.arc(posMissingDot, posY, 3, 0, Math.PI * 2, false)
|
||||
u.ctx.fill()
|
||||
u.ctx.fillText('Missing in DB', posMissingText, posY)
|
||||
}
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText('End', posXLimit + 23, posY)
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
// cursor: { drag: { x: true, y: true } } // Activate zoom
|
||||
plugins: [
|
||||
tooltipPlugin({
|
||||
onclick(u, dataIdx) {
|
||||
if (jobsData) {
|
||||
window.open(`/monitoring/job/${jobsData[dataIdx].id}`)
|
||||
} else if (nodesData) {
|
||||
window.open(`/monitoring/node/${cluster}/${nodesData[dataIdx].nodeName}`)
|
||||
}
|
||||
},
|
||||
getLegendData: (u, dataIdx) => {
|
||||
if (jobsData) {
|
||||
return jobsData[dataIdx]
|
||||
} else if (nodesData) {
|
||||
return nodesData[dataIdx]
|
||||
}
|
||||
}
|
||||
}),
|
||||
],
|
||||
};
|
||||
uplot = new uPlot(opts, plotData, plotWrapper);
|
||||
uplot = new uPlot(opts, roofData, plotWrapper);
|
||||
} else {
|
||||
// console.log("No data for roofline!");
|
||||
}
|
||||
@@ -365,7 +839,7 @@
|
||||
|
||||
/* On Mount */
|
||||
onMount(() => {
|
||||
render(data);
|
||||
render(roofData, jobsData, nodesData);
|
||||
});
|
||||
|
||||
/* On Destroy */
|
||||
@@ -375,10 +849,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data != null}
|
||||
{#if roofData != null}
|
||||
<div bind:this={plotWrapper} class="p-2"></div>
|
||||
{:else}
|
||||
<Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card
|
||||
>
|
||||
<Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card>
|
||||
{/if}
|
||||
|
||||
|
384
web/frontend/src/generic/plots/RooflineLegacy.svelte
Normal file
384
web/frontend/src/generic/plots/RooflineLegacy.svelte
Normal file
@@ -0,0 +1,384 @@
|
||||
<!--
|
||||
@component Roofline Model Plot based on uPlot
|
||||
|
||||
Properties:
|
||||
- `data [null, [], []]`: Roofline Data Structure, see below for details [Default: null]
|
||||
- `renderTime Bool?`: If time information should be rendered as colored dots [Default: false]
|
||||
- `allowSizeChange Bool?`: If dimensions of rendered plot can change [Default: false]
|
||||
- `subCluster GraphQL.SubCluster?`: SubCluster Object; contains required topology information [Default: null]
|
||||
- `width Number?`: Plot width (reactively adaptive) [Default: 600]
|
||||
- `height Number?`: Plot height (reactively adaptive) [Default: 380]
|
||||
|
||||
Data Format:
|
||||
- `data = [null, [], []]`
|
||||
- Index 0: null-axis required for scatter
|
||||
- Index 1: Array of XY-Arrays for Scatter
|
||||
- Index 2: Optional Time Info
|
||||
- `data[1][0] = [100, 200, 500, ...]`
|
||||
- X Axis: Intensity (Vals up to clusters' flopRateScalar value)
|
||||
- `data[1][1] = [1000, 2000, 1500, ...]`
|
||||
- Y Axis: Performance (Vals up to clusters' flopRateSimd value)
|
||||
- `data[2] = [0.1, 0.15, 0.2, ...]`
|
||||
- Color Code: Time Information (Floats from 0 to 1) (Optional)
|
||||
-->
|
||||
|
||||
<script>
|
||||
import uPlot from "uplot";
|
||||
import { formatNumber } from "../units.js";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { Card } from "@sveltestrap/sveltestrap";
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
data = null,
|
||||
renderTime = false,
|
||||
allowSizeChange = false,
|
||||
subCluster = null,
|
||||
width = 600,
|
||||
height = 380,
|
||||
} = $props();
|
||||
|
||||
/* Const Init */
|
||||
const lineWidth = clusterCockpitConfig?.plot_general_lineWidth || 2;
|
||||
const cbmode = clusterCockpitConfig?.plot_general_colorblindMode || false;
|
||||
|
||||
/* Var Init */
|
||||
let timeoutId = null;
|
||||
|
||||
/* State Init */
|
||||
let plotWrapper = $state(null);
|
||||
let uplot = $state(null);
|
||||
|
||||
/* Effect */
|
||||
$effect(() => {
|
||||
if (allowSizeChange) sizeChanged(width, height);
|
||||
});
|
||||
|
||||
/* Functions */
|
||||
// Helper
|
||||
function getGradientR(x) {
|
||||
if (x < 0.5) return 0;
|
||||
if (x > 0.75) return 255;
|
||||
x = (x - 0.5) * 4.0;
|
||||
return Math.floor(x * 255.0);
|
||||
}
|
||||
function getGradientG(x) {
|
||||
if (x > 0.25 && x < 0.75) return 255;
|
||||
if (x < 0.25) x = x * 4.0;
|
||||
else x = 1.0 - (x - 0.75) * 4.0;
|
||||
return Math.floor(x * 255.0);
|
||||
}
|
||||
function getGradientB(x) {
|
||||
if (x < 0.25) return 255;
|
||||
if (x > 0.5) return 0;
|
||||
x = 1.0 - (x - 0.25) * 4.0;
|
||||
return Math.floor(x * 255.0);
|
||||
}
|
||||
function getRGB(c) {
|
||||
return `rgb(${cbmode ? '0' : getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)})`;
|
||||
}
|
||||
function nearestThousand(num) {
|
||||
return Math.ceil(num / 1000) * 1000;
|
||||
}
|
||||
function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
|
||||
let l = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
|
||||
let a = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / l;
|
||||
return {
|
||||
x: x1 + a * (x2 - x1),
|
||||
y: y1 + a * (y2 - y1),
|
||||
};
|
||||
}
|
||||
|
||||
// Dot Renderers
|
||||
const drawColorPoints = (u, seriesIdx, idx0, idx1) => {
|
||||
const size = 5 * devicePixelRatio;
|
||||
uPlot.orient(
|
||||
u,
|
||||
seriesIdx,
|
||||
(
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
moveTo,
|
||||
lineTo,
|
||||
rect,
|
||||
arc,
|
||||
) => {
|
||||
let d = u.data[seriesIdx];
|
||||
let deg360 = 2 * Math.PI;
|
||||
for (let i = 0; i < d[0].length; i++) {
|
||||
let p = new Path2D();
|
||||
let xVal = d[0][i];
|
||||
let yVal = d[1][i];
|
||||
u.ctx.strokeStyle = getRGB(u.data[2][i]);
|
||||
u.ctx.fillStyle = getRGB(u.data[2][i]);
|
||||
if (
|
||||
xVal >= scaleX.min &&
|
||||
xVal <= scaleX.max &&
|
||||
yVal >= scaleY.min &&
|
||||
yVal <= scaleY.max
|
||||
) {
|
||||
let cx = valToPosX(xVal, scaleX, xDim, xOff);
|
||||
let cy = valToPosY(yVal, scaleY, yDim, yOff);
|
||||
|
||||
p.moveTo(cx + size / 2, cy);
|
||||
arc(p, cx, cy, size / 2, 0, deg360);
|
||||
}
|
||||
u.ctx.fill(p);
|
||||
}
|
||||
},
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
const drawPoints = (u, seriesIdx, idx0, idx1) => {
|
||||
const size = 5 * devicePixelRatio;
|
||||
uPlot.orient(
|
||||
u,
|
||||
seriesIdx,
|
||||
(
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
moveTo,
|
||||
lineTo,
|
||||
rect,
|
||||
arc,
|
||||
) => {
|
||||
let d = u.data[seriesIdx];
|
||||
u.ctx.strokeStyle = getRGB(0);
|
||||
u.ctx.fillStyle = getRGB(0);
|
||||
let deg360 = 2 * Math.PI;
|
||||
let p = new Path2D();
|
||||
for (let i = 0; i < d[0].length; i++) {
|
||||
let xVal = d[0][i];
|
||||
let yVal = d[1][i];
|
||||
if (
|
||||
xVal >= scaleX.min &&
|
||||
xVal <= scaleX.max &&
|
||||
yVal >= scaleY.min &&
|
||||
yVal <= scaleY.max
|
||||
) {
|
||||
let cx = valToPosX(xVal, scaleX, xDim, xOff);
|
||||
let cy = valToPosY(yVal, scaleY, yDim, yOff);
|
||||
p.moveTo(cx + size / 2, cy);
|
||||
arc(p, cx, cy, size / 2, 0, deg360);
|
||||
}
|
||||
}
|
||||
u.ctx.fill(p);
|
||||
},
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Main Functions
|
||||
function sizeChanged() {
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
if (uplot) uplot.destroy();
|
||||
render(data);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function render(plotData) {
|
||||
if (plotData) {
|
||||
const opts = {
|
||||
title: "CPU Roofline Diagram",
|
||||
mode: 2,
|
||||
width: width,
|
||||
height: height,
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
cursor: { drag: { x: false, y: false } },
|
||||
axes: [
|
||||
{
|
||||
label: "Intensity [FLOPS/Byte]",
|
||||
values: (u, vals) => vals.map((v) => formatNumber(v)),
|
||||
},
|
||||
{
|
||||
label: "Performace [GFLOPS]",
|
||||
values: (u, vals) => vals.map((v) => formatNumber(v)),
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: {
|
||||
time: false,
|
||||
range: [0.01, 1000],
|
||||
distr: 3, // Render as log
|
||||
log: 10, // log exp
|
||||
},
|
||||
y: {
|
||||
range: [
|
||||
1.0,
|
||||
subCluster?.flopRateSimd?.value
|
||||
? nearestThousand(subCluster.flopRateSimd.value)
|
||||
: 10000,
|
||||
],
|
||||
distr: 3, // Render as log
|
||||
log: 10, // log exp
|
||||
},
|
||||
},
|
||||
series: [{}, { paths: renderTime ? drawColorPoints : drawPoints }],
|
||||
hooks: {
|
||||
drawClear: [
|
||||
(u) => {
|
||||
u.series.forEach((s, i) => {
|
||||
if (i > 0) s._paths = null;
|
||||
});
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
(u) => {
|
||||
// draw roofs when subCluster set
|
||||
if (subCluster != null) {
|
||||
const padding = u._padding; // [top, right, bottom, left]
|
||||
|
||||
u.ctx.strokeStyle = "black";
|
||||
u.ctx.lineWidth = lineWidth;
|
||||
u.ctx.beginPath();
|
||||
|
||||
const ycut = 0.01 * subCluster.memoryBandwidth.value;
|
||||
const scalarKnee =
|
||||
(subCluster.flopRateScalar.value - ycut) /
|
||||
subCluster.memoryBandwidth.value;
|
||||
const simdKnee =
|
||||
(subCluster.flopRateSimd.value - ycut) /
|
||||
subCluster.memoryBandwidth.value;
|
||||
const scalarKneeX = u.valToPos(scalarKnee, "x", true), // Value, axis, toCanvasPixels
|
||||
simdKneeX = u.valToPos(simdKnee, "x", true),
|
||||
flopRateScalarY = u.valToPos(
|
||||
subCluster.flopRateScalar.value,
|
||||
"y",
|
||||
true,
|
||||
),
|
||||
flopRateSimdY = u.valToPos(
|
||||
subCluster.flopRateSimd.value,
|
||||
"y",
|
||||
true,
|
||||
);
|
||||
|
||||
if (
|
||||
scalarKneeX <
|
||||
width * window.devicePixelRatio -
|
||||
padding[1] * window.devicePixelRatio
|
||||
) {
|
||||
// Lower horizontal roofline
|
||||
u.ctx.moveTo(scalarKneeX, flopRateScalarY);
|
||||
u.ctx.lineTo(
|
||||
width * window.devicePixelRatio -
|
||||
padding[1] * window.devicePixelRatio,
|
||||
flopRateScalarY,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
simdKneeX <
|
||||
width * window.devicePixelRatio -
|
||||
padding[1] * window.devicePixelRatio
|
||||
) {
|
||||
// Top horitontal roofline
|
||||
u.ctx.moveTo(simdKneeX, flopRateSimdY);
|
||||
u.ctx.lineTo(
|
||||
width * window.devicePixelRatio -
|
||||
padding[1] * window.devicePixelRatio,
|
||||
flopRateSimdY,
|
||||
);
|
||||
}
|
||||
|
||||
let x1 = u.valToPos(0.01, "x", true),
|
||||
y1 = u.valToPos(ycut, "y", true);
|
||||
|
||||
let x2 = u.valToPos(simdKnee, "x", true),
|
||||
y2 = flopRateSimdY;
|
||||
|
||||
let xAxisIntersect = lineIntersect(
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
u.valToPos(0.01, "x", true),
|
||||
u.valToPos(1.0, "y", true), // X-Axis Start Coords
|
||||
u.valToPos(1000, "x", true),
|
||||
u.valToPos(1.0, "y", true), // X-Axis End Coords
|
||||
);
|
||||
|
||||
if (xAxisIntersect.x > x1) {
|
||||
x1 = xAxisIntersect.x;
|
||||
y1 = xAxisIntersect.y;
|
||||
}
|
||||
|
||||
// Diagonal
|
||||
u.ctx.moveTo(x1, y1);
|
||||
u.ctx.lineTo(x2, y2);
|
||||
|
||||
u.ctx.stroke();
|
||||
// Reset grid lineWidth
|
||||
u.ctx.lineWidth = 0.15;
|
||||
}
|
||||
if (renderTime) {
|
||||
// The Color Scale For Time Information
|
||||
const posX = u.valToPos(0.1, "x", true)
|
||||
const posXLimit = u.valToPos(100, "x", true)
|
||||
const posY = u.valToPos(14000.0, "y", true)
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText('Start', posX, posY)
|
||||
const start = posX + 10
|
||||
for (let x = start; x < posXLimit; x += 10) {
|
||||
let c = (x - start) / (posXLimit - start)
|
||||
u.ctx.fillStyle = getRGB(c)
|
||||
u.ctx.beginPath()
|
||||
u.ctx.arc(x, posY, 3, 0, Math.PI * 2, false)
|
||||
u.ctx.fill()
|
||||
}
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText('End', posXLimit + 23, posY)
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
// cursor: { drag: { x: true, y: true } } // Activate zoom
|
||||
};
|
||||
uplot = new uPlot(opts, plotData, plotWrapper);
|
||||
} else {
|
||||
// console.log("No data for roofline!");
|
||||
}
|
||||
}
|
||||
|
||||
/* On Mount */
|
||||
onMount(() => {
|
||||
render(data);
|
||||
});
|
||||
|
||||
/* On Destroy */
|
||||
onDestroy(() => {
|
||||
if (uplot) uplot.destroy();
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data != null}
|
||||
<div bind:this={plotWrapper} class="p-2"></div>
|
||||
{:else}
|
||||
<Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card
|
||||
>
|
||||
{/if}
|
||||
|
@@ -3,8 +3,9 @@
|
||||
|
||||
Properties:
|
||||
- `cluster String`: Currently selected cluster
|
||||
- `selectedHistograms [String]`: The currently selected metrics to display as histogram
|
||||
- `ìsOpen Bool`: Is selection opened [Bindable]
|
||||
- `configName String`: The config id string to be updated in database on selection change
|
||||
- `presetSelectedHistograms [String]`: The currently selected metrics to display as histogram
|
||||
- `applyChange Func`: The callback function to apply current selection
|
||||
-->
|
||||
|
||||
@@ -25,6 +26,7 @@
|
||||
let {
|
||||
cluster,
|
||||
isOpen = $bindable(),
|
||||
configName,
|
||||
presetSelectedHistograms,
|
||||
applyChange
|
||||
} = $props();
|
||||
@@ -67,8 +69,8 @@
|
||||
applyChange(selectedHistograms)
|
||||
updateConfiguration({
|
||||
name: cluster
|
||||
? `user_view_histogramMetrics:${cluster}`
|
||||
: "user_view_histogramMetrics",
|
||||
? `${configName}:${cluster}`
|
||||
: configName,
|
||||
value: selectedHistograms,
|
||||
});
|
||||
}
|
||||
|
@@ -96,9 +96,9 @@
|
||||
function printAvailability(metric, cluster) {
|
||||
const avail = globalMetrics.find((gm) => gm.name === metric)?.availability
|
||||
if (!cluster) {
|
||||
return avail.map((av) => av.cluster).join(',')
|
||||
return avail.map((av) => av.cluster).join(', ')
|
||||
} else {
|
||||
return avail.find((av) => av.cluster === cluster).subClusters.join(',')
|
||||
return avail.find((av) => av.cluster === cluster).subClusters.join(', ')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{metric}
|
||||
<span style="float: right;">
|
||||
<span style="float: right; text-align: justify;">
|
||||
{printAvailability(metric, cluster)}
|
||||
</span>
|
||||
</li>
|
||||
|
@@ -19,7 +19,7 @@
|
||||
import {
|
||||
transformDataForRoofline,
|
||||
} from "../generic/utils.js";
|
||||
import Roofline from "../generic/plots/Roofline.svelte";
|
||||
import Roofline from "../generic/plots/RooflineLegacy.svelte";
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
|
@@ -5,7 +5,7 @@ import Status from './Status.root.svelte'
|
||||
mount(Status, {
|
||||
target: document.getElementById('svelte-app'),
|
||||
props: {
|
||||
cluster: infos.cluster,
|
||||
presetCluster: infos.cluster,
|
||||
},
|
||||
context: new Map([
|
||||
['cc-config', clusterCockpitConfig]
|
||||
|
159
web/frontend/src/status/StatisticsDash.svelte
Normal file
159
web/frontend/src/status/StatisticsDash.svelte
Normal file
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
@component Main cluster status view component; renders current system-usage information
|
||||
|
||||
Properties:
|
||||
- `presetCluster String`: The cluster to show status information for
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Spinner,
|
||||
Card,
|
||||
Icon,
|
||||
Button,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
getContextClient,
|
||||
} from "@urql/svelte";
|
||||
import {
|
||||
init,
|
||||
convert2uplot,
|
||||
} from "../generic/utils.js";
|
||||
import PlotGrid from "../generic/PlotGrid.svelte";
|
||||
import Histogram from "../generic/plots/Histogram.svelte";
|
||||
import HistogramSelection from "../generic/select/HistogramSelection.svelte";
|
||||
import Refresher from "../generic/helper/Refresher.svelte";
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
presetCluster
|
||||
} = $props();
|
||||
|
||||
/* Const Init */
|
||||
const { query: initq } = init();
|
||||
const ccconfig = getContext("cc-config");
|
||||
const client = getContextClient();
|
||||
|
||||
/* State Init */
|
||||
let cluster = $state(presetCluster);
|
||||
// Histogram
|
||||
let isHistogramSelectionOpen = $state(false);
|
||||
let from = $state(new Date(Date.now() - (30 * 24 * 60 * 60 * 1000))); // Simple way to retrigger GQL: Jobs Started last Month
|
||||
let to = $state(new Date(Date.now()));
|
||||
|
||||
/* Derived */
|
||||
let selectedHistograms = $derived(cluster
|
||||
? ccconfig[`status_view_selectedHistograms:${cluster}`] || ( ccconfig['status_view_selectedHistograms'] || [] )
|
||||
: ccconfig['status_view_selectedHistograms'] || []);
|
||||
|
||||
// Note: nodeMetrics are requested on configured $timestep resolution
|
||||
const metricStatusQuery = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query (
|
||||
$filter: [JobFilter!]!
|
||||
$selectedHistograms: [String!]
|
||||
) {
|
||||
jobsStatistics(filter: $filter, metrics: $selectedHistograms) {
|
||||
histMetrics {
|
||||
metric
|
||||
unit
|
||||
data {
|
||||
min
|
||||
max
|
||||
count
|
||||
bin
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ state: ["running"] }, { cluster: { eq: cluster}}, {startTime: { from, to }}],
|
||||
selectedHistograms: selectedHistograms,
|
||||
},
|
||||
}));
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Loading indicators & Metric Sleect -->
|
||||
<Row class="justify-content-between">
|
||||
<Col class="mb-2 mb-md-0" xs="12" md="5" lg="4" xl="3">
|
||||
<Button
|
||||
outline
|
||||
color="secondary"
|
||||
onclick={() => (isHistogramSelectionOpen = true)}
|
||||
>
|
||||
<Icon name="bar-chart-line" /> Select Histograms
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs="12" md="5" lg="4" xl="3">
|
||||
<Refresher
|
||||
initially={120}
|
||||
onRefresh={() => {
|
||||
from = new Date(Date.now() - (30 * 24 * 60 * 60 * 1000)); // Triggers GQL
|
||||
to = new Date(Date.now());
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row cols={1} class="text-center mt-3">
|
||||
<Col>
|
||||
{#if $initq.fetching || $metricStatusQuery.fetching}
|
||||
<Spinner />
|
||||
{:else if $initq.error}
|
||||
<Card body color="danger">{$initq.error.message}</Card>
|
||||
{:else}
|
||||
<!-- ... -->
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
{#if $metricStatusQuery.error}
|
||||
<Row cols={1}>
|
||||
<Col>
|
||||
<Card body color="danger">{$metricStatusQuery.error.message}</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
|
||||
{#if $initq.data && $metricStatusQuery.data}
|
||||
<!-- Selectable Stats as Histograms : Average Values of Running Jobs -->
|
||||
{#if selectedHistograms}
|
||||
<!-- Note: Ignore '#snippet' Error in IDE -->
|
||||
{#snippet gridContent(item)}
|
||||
<Histogram
|
||||
data={convert2uplot(item.data)}
|
||||
title="Distribution of '{item.metric}' averages"
|
||||
xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`}
|
||||
xunit={item.unit}
|
||||
ylabel="Number of Jobs"
|
||||
yunit="Jobs"
|
||||
usesBins
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#key $metricStatusQuery.data.jobsStatistics[0].histMetrics}
|
||||
<PlotGrid
|
||||
items={$metricStatusQuery.data.jobsStatistics[0].histMetrics}
|
||||
itemsPerRow={2}
|
||||
{gridContent}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<HistogramSelection
|
||||
{cluster}
|
||||
bind:isOpen={isHistogramSelectionOpen}
|
||||
presetSelectedHistograms={selectedHistograms}
|
||||
configName="status_view_selectedHistograms"
|
||||
applyChange={(newSelection) => {
|
||||
selectedHistograms = [...newSelection];
|
||||
}}
|
||||
/>
|
580
web/frontend/src/status/StatusDash.svelte
Normal file
580
web/frontend/src/status/StatusDash.svelte
Normal file
@@ -0,0 +1,580 @@
|
||||
<!--
|
||||
@component Main cluster status view component; renders current system-usage information
|
||||
|
||||
Properties:
|
||||
- `presetCluster String`: The cluster to show status information for
|
||||
-->
|
||||
|
||||
<script>
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardBody,
|
||||
Table,
|
||||
Progress,
|
||||
Icon,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
getContextClient,
|
||||
} from "@urql/svelte";
|
||||
import {
|
||||
init,
|
||||
} from "../generic/utils.js";
|
||||
import { scaleNumbers, formatTime } from "../generic/units.js";
|
||||
import Refresher from "../generic/helper/Refresher.svelte";
|
||||
import Roofline from "../generic/plots/Roofline.svelte";
|
||||
import Pie, { colors } from "../generic/plots/Pie.svelte";
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
presetCluster,
|
||||
useCbColors = false,
|
||||
useAltColors = false,
|
||||
} = $props();
|
||||
|
||||
/* Const Init */
|
||||
const { query: initq } = init();
|
||||
const client = getContextClient();
|
||||
|
||||
/* State Init */
|
||||
let cluster = $state(presetCluster);
|
||||
let pieWidth = $state(0);
|
||||
let plotWidths = $state([]);
|
||||
let from = $state(new Date(Date.now() - 5 * 60 * 1000));
|
||||
let to = $state(new Date(Date.now()));
|
||||
// Bar Gauges
|
||||
let allocatedNodes = $state({});
|
||||
let allocatedAccs = $state({});
|
||||
let flopRate = $state({});
|
||||
let flopRateUnitPrefix = $state({});
|
||||
let flopRateUnitBase = $state({});
|
||||
let memBwRate = $state({});
|
||||
let memBwRateUnitPrefix = $state({});
|
||||
let memBwRateUnitBase = $state({});
|
||||
// Plain Infos
|
||||
let runningJobs = $state({});
|
||||
let activeUsers = $state({});
|
||||
let totalAccs = $state({});
|
||||
|
||||
/* Derived */
|
||||
// Accumulated NodeStates for Piecharts
|
||||
const nodesStateCounts = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query ($filter: [NodeFilter!]) {
|
||||
nodeStates(filter: $filter) {
|
||||
state
|
||||
count
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: { cluster: { eq: cluster }}
|
||||
},
|
||||
}));
|
||||
|
||||
const refinedStateData = $derived.by(() => {
|
||||
return $nodesStateCounts?.data?.nodeStates.filter((e) => ['allocated', 'reserved', 'idle', 'mixed','down', 'unknown'].includes(e.state))
|
||||
});
|
||||
|
||||
const refinedHealthData = $derived.by(() => {
|
||||
return $nodesStateCounts?.data?.nodeStates.filter((e) => ['full', 'partial', 'failed'].includes(e.state))
|
||||
});
|
||||
|
||||
// Note: nodeMetrics are requested on configured $timestep resolution
|
||||
// Result: The latest 5 minutes (datapoints) for each node independent of job
|
||||
const statusQuery = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query (
|
||||
$cluster: String!
|
||||
$metrics: [String!]
|
||||
$from: Time!
|
||||
$to: Time!
|
||||
$jobFilter: [JobFilter!]!
|
||||
$nodeFilter: [NodeFilter!]!
|
||||
$paging: PageRequest!
|
||||
$sorting: OrderByInput!
|
||||
) {
|
||||
# Node 5 Minute Averages for Roofline
|
||||
nodeMetrics(
|
||||
cluster: $cluster
|
||||
metrics: $metrics
|
||||
from: $from
|
||||
to: $to
|
||||
) {
|
||||
host
|
||||
subCluster
|
||||
metrics {
|
||||
name
|
||||
metric {
|
||||
series {
|
||||
statistics {
|
||||
avg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Running Job Metric Average for Rooflines
|
||||
jobsMetricStats(filter: $jobFilter, metrics: $metrics) {
|
||||
id
|
||||
jobId
|
||||
duration
|
||||
numNodes
|
||||
numAccelerators
|
||||
subCluster
|
||||
stats {
|
||||
name
|
||||
data {
|
||||
avg
|
||||
}
|
||||
}
|
||||
}
|
||||
# Get Jobs for Per-Node Counts
|
||||
jobs(filter: $jobFilter, order: $sorting, page: $paging) {
|
||||
items {
|
||||
jobId
|
||||
resources {
|
||||
hostname
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
# Only counts shared nodes once
|
||||
allocatedNodes(cluster: $cluster) {
|
||||
name
|
||||
count
|
||||
}
|
||||
# Get States for Node Roofline; $sorting unused in backend: Use placeholder
|
||||
nodes(filter: $nodeFilter, order: $sorting) {
|
||||
count
|
||||
items {
|
||||
hostname
|
||||
cluster
|
||||
subCluster
|
||||
nodeState
|
||||
}
|
||||
}
|
||||
# totalNodes includes multiples if shared jobs
|
||||
jobsStatistics(
|
||||
filter: $jobFilter
|
||||
page: $paging
|
||||
sortBy: TOTALJOBS
|
||||
groupBy: SUBCLUSTER
|
||||
) {
|
||||
id
|
||||
totalJobs
|
||||
totalUsers
|
||||
totalAccs
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
cluster: cluster,
|
||||
metrics: ["flops_any", "mem_bw"], // Fixed names for roofline and status bars
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
jobFilter: [{ state: ["running"] }, { cluster: { eq: cluster } }],
|
||||
nodeFilter: { cluster: { eq: cluster }},
|
||||
paging: { itemsPerPage: -1, page: 1 }, // Get all: -1
|
||||
sorting: { field: "startTime", type: "col", order: "DESC" }
|
||||
},
|
||||
}));
|
||||
|
||||
/* Effects */
|
||||
$effect(() => {
|
||||
if ($initq.data && $statusQuery.data) {
|
||||
let subClusters = $initq.data.clusters.find(
|
||||
(c) => c.name == cluster,
|
||||
).subClusters;
|
||||
for (let subCluster of subClusters) {
|
||||
// Allocations
|
||||
allocatedNodes[subCluster.name] =
|
||||
$statusQuery.data.allocatedNodes.find(
|
||||
({ name }) => name == subCluster.name,
|
||||
)?.count || 0;
|
||||
allocatedAccs[subCluster.name] =
|
||||
$statusQuery.data.jobsStatistics.find(
|
||||
({ id }) => id == subCluster.name,
|
||||
)?.totalAccs || 0;
|
||||
// Infos
|
||||
activeUsers[subCluster.name] =
|
||||
$statusQuery.data.jobsStatistics.find(
|
||||
({ id }) => id == subCluster.name,
|
||||
)?.totalUsers || 0;
|
||||
runningJobs[subCluster.name] =
|
||||
$statusQuery.data.jobsStatistics.find(
|
||||
({ id }) => id == subCluster.name,
|
||||
)?.totalJobs || 0;
|
||||
totalAccs[subCluster.name] =
|
||||
(subCluster?.numberOfNodes * subCluster?.topology?.accelerators?.length) || null;
|
||||
// Keymetrics
|
||||
flopRate[subCluster.name] =
|
||||
Math.floor(
|
||||
sumUp($statusQuery.data.nodeMetrics, subCluster.name, "flops_any") *
|
||||
100,
|
||||
) / 100;
|
||||
flopRateUnitPrefix[subCluster.name] = subCluster.flopRateSimd.unit.prefix;
|
||||
flopRateUnitBase[subCluster.name] = subCluster.flopRateSimd.unit.base;
|
||||
memBwRate[subCluster.name] =
|
||||
Math.floor(
|
||||
sumUp($statusQuery.data.nodeMetrics, subCluster.name, "mem_bw") * 100,
|
||||
) / 100;
|
||||
memBwRateUnitPrefix[subCluster.name] =
|
||||
subCluster.memoryBandwidth.unit.prefix;
|
||||
memBwRateUnitBase[subCluster.name] = subCluster.memoryBandwidth.unit.base;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* Const Functions */
|
||||
const sumUp = (data, subcluster, metric) =>
|
||||
data.reduce(
|
||||
(sum, node) =>
|
||||
node.subCluster == subcluster
|
||||
? sum +
|
||||
(node.metrics
|
||||
.find((m) => m.name == metric)
|
||||
?.metric?.series[0]?.statistics?.avg || 0
|
||||
)
|
||||
: sum,
|
||||
0,
|
||||
);
|
||||
|
||||
/* Functions */
|
||||
function transformJobsStatsToData(subclusterData) {
|
||||
/* c will contain values from 0 to 1 representing the duration */
|
||||
let data = null
|
||||
const x = [], y = [], c = [], day = 86400.0
|
||||
|
||||
if (subclusterData) {
|
||||
for (let i = 0; i < subclusterData.length; i++) {
|
||||
const flopsData = subclusterData[i].stats.find((s) => s.name == "flops_any")
|
||||
const memBwData = subclusterData[i].stats.find((s) => s.name == "mem_bw")
|
||||
|
||||
const f = flopsData.data.avg
|
||||
const m = memBwData.data.avg
|
||||
const d = subclusterData[i].duration / day
|
||||
|
||||
const intensity = f / m
|
||||
if (Number.isNaN(intensity) || !Number.isFinite(intensity))
|
||||
continue
|
||||
|
||||
x.push(intensity)
|
||||
y.push(f)
|
||||
// Long Jobs > 1 Day: Use max Color
|
||||
if (d > 1.0) c.push(1.0)
|
||||
else c.push(d)
|
||||
}
|
||||
} else {
|
||||
console.warn("transformJobsStatsToData: metrics for 'mem_bw' and/or 'flops_any' missing!")
|
||||
}
|
||||
|
||||
if (x.length > 0 && y.length > 0 && c.length > 0) {
|
||||
data = [null, [x, y], c] // for dataformat see roofline.svelte
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function transformNodesStatsToData(subclusterData) {
|
||||
let data = null
|
||||
const x = [], y = []
|
||||
|
||||
if (subclusterData) {
|
||||
for (let i = 0; i < subclusterData.length; i++) {
|
||||
const flopsData = subclusterData[i].metrics.find((s) => s.name == "flops_any")
|
||||
const memBwData = subclusterData[i].metrics.find((s) => s.name == "mem_bw")
|
||||
|
||||
const f = flopsData.metric.series[0].statistics.avg
|
||||
const m = memBwData.metric.series[0].statistics.avg
|
||||
|
||||
let intensity = f / m
|
||||
if (Number.isNaN(intensity) || !Number.isFinite(intensity)) {
|
||||
intensity = 0.0 // Set to Float Zero: Will not show in Log-Plot (Always below render limit)
|
||||
}
|
||||
|
||||
x.push(intensity)
|
||||
y.push(f)
|
||||
}
|
||||
} else {
|
||||
// console.warn("transformNodesStatsToData: metrics for 'mem_bw' and/or 'flops_any' missing!")
|
||||
}
|
||||
|
||||
if (x.length > 0 && y.length > 0) {
|
||||
data = [null, [x, y]] // for dataformat see roofline.svelte
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function transformJobsStatsToInfo(subclusterData) {
|
||||
if (subclusterData) {
|
||||
return subclusterData.map((sc) => { return {id: sc.id, jobId: sc.jobId, numNodes: sc.numNodes, numAcc: sc?.numAccelerators? sc.numAccelerators : 0, duration: formatTime(sc.duration)} })
|
||||
} else {
|
||||
console.warn("transformJobsStatsToInfo: jobInfo missing!")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function transformNodesStatsToInfo(subClusterData) {
|
||||
let result = [];
|
||||
if (subClusterData) { // && $nodesState?.data) {
|
||||
// Use Nodes as Returned from CCMS, *NOT* as saved in DB via SlurmState-API!
|
||||
for (let j = 0; j < subClusterData.length; j++) {
|
||||
const nodeName = subClusterData[j]?.host ? subClusterData[j].host : "unknown"
|
||||
const nodeMatch = $statusQuery?.data?.nodes?.items?.find((n) => n.hostname == nodeName && n.subCluster == subClusterData[j].subCluster);
|
||||
const nodeState = nodeMatch?.nodeState ? nodeMatch.nodeState : "notindb"
|
||||
let numJobs = 0
|
||||
|
||||
if ($statusQuery?.data) {
|
||||
const nodeJobs = $statusQuery?.data?.jobs?.items?.filter((job) => job.resources.find((res) => res.hostname == nodeName))
|
||||
numJobs = nodeJobs?.length ? nodeJobs.length : 0
|
||||
}
|
||||
|
||||
result.push({nodeName: nodeName, nodeState: nodeState, numJobs: numJobs})
|
||||
};
|
||||
};
|
||||
return result
|
||||
}
|
||||
|
||||
function legendColors(targetIdx) {
|
||||
// Reuses first color if targetIdx overflows
|
||||
let c;
|
||||
if (useCbColors) {
|
||||
c = [...colors['colorblind']];
|
||||
} else if (useAltColors) {
|
||||
c = [...colors['alternative']];
|
||||
} else {
|
||||
c = [...colors['default']];
|
||||
}
|
||||
return c[(c.length + targetIdx) % c.length];
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Refresher and space for other options -->
|
||||
<Row class="justify-content-end">
|
||||
<Col xs="12" md="5" lg="4" xl="3">
|
||||
<Refresher
|
||||
initially={120}
|
||||
onRefresh={() => {
|
||||
from = new Date(Date.now() - 5 * 60 * 1000);
|
||||
to = new Date(Date.now());
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- Node Health Pis, later Charts -->
|
||||
{#if $initq.data && $nodesStateCounts.data}
|
||||
<Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center">
|
||||
<Col class="px-3 mt-2 mt-lg-0">
|
||||
<div bind:clientWidth={pieWidth}>
|
||||
{#key refinedStateData}
|
||||
<h4 class="text-center">
|
||||
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States
|
||||
</h4>
|
||||
<Pie
|
||||
{useAltColors}
|
||||
canvasId="hpcpie-slurm"
|
||||
size={pieWidth * 0.55}
|
||||
sliceLabel="Nodes"
|
||||
quantities={refinedStateData.map(
|
||||
(sd) => sd.count,
|
||||
)}
|
||||
entities={refinedStateData.map(
|
||||
(sd) => sd.state,
|
||||
)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</Col>
|
||||
<Col class="px-4 py-2">
|
||||
{#key refinedStateData}
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th></th>
|
||||
<th>Current State</th>
|
||||
<th>Nodes</th>
|
||||
</tr>
|
||||
{#each refinedStateData as sd, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {legendColors(i)};"/></td>
|
||||
<td>{sd.state}</td>
|
||||
<td>{sd.count}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
{/key}
|
||||
</Col>
|
||||
|
||||
<Col class="px-3 mt-2 mt-lg-0">
|
||||
<div bind:clientWidth={pieWidth}>
|
||||
{#key refinedHealthData}
|
||||
<h4 class="text-center">
|
||||
{cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node Health
|
||||
</h4>
|
||||
<Pie
|
||||
{useAltColors}
|
||||
canvasId="hpcpie-health"
|
||||
size={pieWidth * 0.55}
|
||||
sliceLabel="Nodes"
|
||||
quantities={refinedHealthData.map(
|
||||
(sd) => sd.count,
|
||||
)}
|
||||
entities={refinedHealthData.map(
|
||||
(sd) => sd.state,
|
||||
)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</Col>
|
||||
<Col class="px-4 py-2">
|
||||
{#key refinedHealthData}
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th></th>
|
||||
<th>Current Health</th>
|
||||
<th>Nodes</th>
|
||||
</tr>
|
||||
{#each refinedHealthData as hd, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td>
|
||||
<td>{hd.state}</td>
|
||||
<td>{hd.count}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
{/key}
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
|
||||
<hr/>
|
||||
<!-- Gauges & Roofline per Subcluster-->
|
||||
{#if $initq.data && $statusQuery.data}
|
||||
{#each $initq.data.clusters.find((c) => c.name == cluster).subClusters as subCluster, i}
|
||||
<Row cols={{ lg: 3, md: 1 , sm: 1}} class="mb-3 justify-content-center">
|
||||
<Col class="px-3">
|
||||
<Card class="h-auto mt-1">
|
||||
<CardHeader>
|
||||
<CardTitle class="mb-0">SubCluster "{subCluster.name}"</CardTitle>
|
||||
<span>{subCluster.processorType}</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Table borderless>
|
||||
<tr class="py-2">
|
||||
<td style="font-size:x-large;">{runningJobs[subCluster.name]} Running Jobs</td>
|
||||
<td colspan="2" style="font-size:x-large;">{activeUsers[subCluster.name]} Active Users</td>
|
||||
</tr>
|
||||
<hr class="my-1"/>
|
||||
<tr class="pt-2">
|
||||
<td style="font-size: large;">
|
||||
Flop Rate (<span style="cursor: help;" title="Flops[Any] = (Flops[Double] x 2) + Flops[Single]">Any</span>)
|
||||
</td>
|
||||
<td colspan="2" style="font-size: large;">
|
||||
Memory BW Rate
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="pb-2">
|
||||
<td style="font-size:x-large;">
|
||||
{flopRate[subCluster.name]}
|
||||
{flopRateUnitPrefix[subCluster.name]}{flopRateUnitBase[subCluster.name]}
|
||||
</td>
|
||||
<td colspan="2" style="font-size:x-large;">
|
||||
{memBwRate[subCluster.name]}
|
||||
{memBwRateUnitPrefix[subCluster.name]}{memBwRateUnitBase[subCluster.name]}
|
||||
</td>
|
||||
</tr>
|
||||
<hr class="my-1"/>
|
||||
<tr class="py-2">
|
||||
<th scope="col">Allocated Nodes</th>
|
||||
<td style="min-width: 100px;"
|
||||
><div class="col">
|
||||
<Progress
|
||||
value={allocatedNodes[subCluster.name]}
|
||||
max={subCluster.numberOfNodes}
|
||||
/>
|
||||
</div></td
|
||||
>
|
||||
<td
|
||||
>{allocatedNodes[subCluster.name]} / {subCluster.numberOfNodes}
|
||||
Nodes</td
|
||||
>
|
||||
</tr>
|
||||
{#if totalAccs[subCluster.name] !== null}
|
||||
<tr class="py-2">
|
||||
<th scope="col">Allocated Accelerators</th>
|
||||
<td style="min-width: 100px;"
|
||||
><div class="col">
|
||||
<Progress
|
||||
value={allocatedAccs[subCluster.name]}
|
||||
max={totalAccs[subCluster.name]}
|
||||
/>
|
||||
</div></td
|
||||
>
|
||||
<td
|
||||
>{allocatedAccs[subCluster.name]} / {totalAccs[subCluster.name]}
|
||||
Accelerators</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col class="px-3 mt-2 mt-lg-0">
|
||||
<div bind:clientWidth={plotWidths[i]}>
|
||||
{#key $statusQuery?.data?.nodeMetrics}
|
||||
<Roofline
|
||||
useColors={true}
|
||||
allowSizeChange
|
||||
width={plotWidths[i] - 10}
|
||||
height={300}
|
||||
cluster={cluster}
|
||||
subCluster={subCluster}
|
||||
roofData={transformNodesStatsToData($statusQuery?.data?.nodeMetrics.filter(
|
||||
(data) => data.subCluster == subCluster.name,
|
||||
)
|
||||
)}
|
||||
nodesData={transformNodesStatsToInfo($statusQuery?.data?.nodeMetrics.filter(
|
||||
(data) => data.subCluster == subCluster.name,
|
||||
)
|
||||
)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</Col>
|
||||
<Col class="px-3 mt-2 mt-lg-0">
|
||||
<div bind:clientWidth={plotWidths[i]}>
|
||||
{#key $statusQuery?.data?.jobsMetricStats}
|
||||
<Roofline
|
||||
useColors={true}
|
||||
allowSizeChange
|
||||
width={plotWidths[i] - 10}
|
||||
height={300}
|
||||
subCluster={subCluster}
|
||||
roofData={transformJobsStatsToData($statusQuery?.data?.jobsMetricStats.filter(
|
||||
(data) => data.subCluster == subCluster.name,
|
||||
)
|
||||
)}
|
||||
jobsData={transformJobsStatsToInfo($statusQuery?.data?.jobsMetricStats.filter(
|
||||
(data) => data.subCluster == subCluster.name,
|
||||
)
|
||||
)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
{/each}
|
||||
{:else}
|
||||
<Card class="mx-4" body color="warning">Cannot render status rooflines: No data!</Card>
|
||||
{/if}
|
547
web/frontend/src/status/UsageDash.svelte
Normal file
547
web/frontend/src/status/UsageDash.svelte
Normal file
@@ -0,0 +1,547 @@
|
||||
<!--
|
||||
@component Main cluster status view component; renders current system-usage information
|
||||
|
||||
Properties:
|
||||
- `presetCluster String`: The cluster to show status information for
|
||||
-->
|
||||
|
||||
<script>
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Spinner,
|
||||
Card,
|
||||
Table,
|
||||
Icon,
|
||||
Tooltip,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupText
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
getContextClient,
|
||||
} from "@urql/svelte";
|
||||
import {
|
||||
init,
|
||||
scramble,
|
||||
scrambleNames,
|
||||
convert2uplot,
|
||||
} from "../generic/utils.js";
|
||||
import Pie, { colors } from "../generic/plots/Pie.svelte";
|
||||
import Histogram from "../generic/plots/Histogram.svelte";
|
||||
import Refresher from "../generic/helper/Refresher.svelte";
|
||||
|
||||
/* Svelte 5 Props */
|
||||
let {
|
||||
presetCluster,
|
||||
useCbColors = false,
|
||||
useAltColors = false
|
||||
} = $props();
|
||||
|
||||
/* Const Init */
|
||||
const { query: initq } = init();
|
||||
const client = getContextClient();
|
||||
const durationBinOptions = ["1m","10m","1h","6h","12h"];
|
||||
|
||||
/* State Init */
|
||||
let cluster = $state(presetCluster)
|
||||
let from = $state(new Date(Date.now() - (30 * 24 * 60 * 60 * 1000))); // Simple way to retrigger GQL: Jobs Started last Month
|
||||
let to = $state(new Date(Date.now()));
|
||||
let colWidthJobs = $state(0);
|
||||
let colWidthNodes = $state(0);
|
||||
let colWidthAccs = $state(0);
|
||||
let numDurationBins = $state("1h");
|
||||
|
||||
/* Derived */
|
||||
const topJobsQuery = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query (
|
||||
$filter: [JobFilter!]!
|
||||
$paging: PageRequest!
|
||||
) {
|
||||
topUser: jobsStatistics(
|
||||
filter: $filter
|
||||
page: $paging
|
||||
sortBy: TOTALJOBS
|
||||
groupBy: USER
|
||||
) {
|
||||
id
|
||||
name
|
||||
totalJobs
|
||||
}
|
||||
topProjects: jobsStatistics(
|
||||
filter: $filter
|
||||
page: $paging
|
||||
sortBy: TOTALJOBS
|
||||
groupBy: PROJECT
|
||||
) {
|
||||
id
|
||||
totalJobs
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ state: ["running"] }, { cluster: { eq: cluster}}, {startTime: { from, to }}],
|
||||
paging: { itemsPerPage: 10, page: 1 } // Top 10
|
||||
},
|
||||
}));
|
||||
|
||||
const topNodesQuery = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query (
|
||||
$filter: [JobFilter!]!
|
||||
$paging: PageRequest!
|
||||
) {
|
||||
topUser: jobsStatistics(
|
||||
filter: $filter
|
||||
page: $paging
|
||||
sortBy: TOTALNODES
|
||||
groupBy: USER
|
||||
) {
|
||||
id
|
||||
name
|
||||
totalNodes
|
||||
}
|
||||
topProjects: jobsStatistics(
|
||||
filter: $filter
|
||||
page: $paging
|
||||
sortBy: TOTALNODES
|
||||
groupBy: PROJECT
|
||||
) {
|
||||
id
|
||||
totalNodes
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ state: ["running"] }, { cluster: { eq: cluster }}, {startTime: { from, to }}],
|
||||
paging: { itemsPerPage: 10, page: 1 } // Top 10
|
||||
},
|
||||
}));
|
||||
|
||||
const topAccsQuery = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query (
|
||||
$filter: [JobFilter!]!
|
||||
$paging: PageRequest!
|
||||
) {
|
||||
topUser: jobsStatistics(
|
||||
filter: $filter
|
||||
page: $paging
|
||||
sortBy: TOTALACCS
|
||||
groupBy: USER
|
||||
) {
|
||||
id
|
||||
name
|
||||
totalAccs
|
||||
}
|
||||
topProjects: jobsStatistics(
|
||||
filter: $filter
|
||||
page: $paging
|
||||
sortBy: TOTALACCS
|
||||
groupBy: PROJECT
|
||||
) {
|
||||
id
|
||||
totalAccs
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ state: ["running"] }, { cluster: { eq: cluster }}, {startTime: { from, to }}],
|
||||
paging: { itemsPerPage: 10, page: 1 } // Top 10
|
||||
},
|
||||
}));
|
||||
|
||||
// Note: nodeMetrics are requested on configured $timestep resolution
|
||||
const nodeStatusQuery = $derived(queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query (
|
||||
$filter: [JobFilter!]!
|
||||
$selectedHistograms: [String!]
|
||||
$numDurationBins: String
|
||||
) {
|
||||
jobsStatistics(filter: $filter, metrics: $selectedHistograms, numDurationBins: $numDurationBins) {
|
||||
histDuration {
|
||||
count
|
||||
value
|
||||
}
|
||||
histNumNodes {
|
||||
count
|
||||
value
|
||||
}
|
||||
histNumAccs {
|
||||
count
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
filter: [{ state: ["running"] }, { cluster: { eq: cluster }}, {startTime: { from, to }}],
|
||||
selectedHistograms: [], // No Metrics requested for node hardware stats
|
||||
numDurationBins: numDurationBins,
|
||||
},
|
||||
}));
|
||||
|
||||
/* Functions */
|
||||
function legendColors(targetIdx) {
|
||||
// Reuses first color if targetIdx overflows
|
||||
let c;
|
||||
if (useCbColors) {
|
||||
c = [...colors['colorblind']];
|
||||
} else if (useAltColors) {
|
||||
c = [...colors['alternative']];
|
||||
} else {
|
||||
c = [...colors['default']];
|
||||
}
|
||||
return c[(c.length + targetIdx) % c.length];
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Refresher and space for other options -->
|
||||
<Row class="justify-content-between">
|
||||
<Col class="mb-2 mb-md-0" xs="12" md="5" lg="4" xl="3">
|
||||
<InputGroup>
|
||||
<InputGroupText>
|
||||
<Icon name="bar-chart-line-fill" />
|
||||
</InputGroupText>
|
||||
<InputGroupText>
|
||||
Duration Bin Size
|
||||
</InputGroupText>
|
||||
<Input type="select" bind:value={numDurationBins}>
|
||||
{#each durationBinOptions as dbin}
|
||||
<option value={dbin}>{dbin}</option>
|
||||
{/each}
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<Col xs="12" md="5" lg="4" xl="3">
|
||||
<Refresher
|
||||
initially={120}
|
||||
onRefresh={() => {
|
||||
from = new Date(Date.now() - (30 * 24 * 60 * 60 * 1000)); // Triggers GQL
|
||||
to = new Date(Date.now());
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- Job Duration, Top Users and Projects-->
|
||||
{#if $topJobsQuery.fetching || $nodeStatusQuery.fetching}
|
||||
<Spinner />
|
||||
{:else if $topJobsQuery.data && $nodeStatusQuery.data}
|
||||
<Row>
|
||||
<Col xs="12" lg="4" class="p-2">
|
||||
{#key $nodeStatusQuery.data.jobsStatistics[0].histDuration}
|
||||
<Histogram
|
||||
data={convert2uplot($nodeStatusQuery.data.jobsStatistics[0].histDuration)}
|
||||
title="Duration Distribution"
|
||||
xlabel="Current Job Runtimes"
|
||||
xunit="Runtime"
|
||||
ylabel="Number of Jobs"
|
||||
yunit="Jobs"
|
||||
height="275"
|
||||
usesBins
|
||||
xtime
|
||||
/>
|
||||
{/key}
|
||||
</Col>
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<div bind:clientWidth={colWidthJobs}>
|
||||
<h4 class="text-center">
|
||||
Top Users: Jobs
|
||||
</h4>
|
||||
<Pie
|
||||
{useAltColors}
|
||||
canvasId="hpcpie-jobs-users"
|
||||
size={colWidthJobs * 0.75}
|
||||
sliceLabel="Jobs"
|
||||
quantities={$topJobsQuery.data.topUser.map(
|
||||
(tu) => tu['totalJobs'],
|
||||
)}
|
||||
entities={$topJobsQuery.data.topUser.map((tu) => scrambleNames ? scramble(tu.id) : tu.id)}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th></th>
|
||||
<th style="padding-left: 0.5rem;">User</th>
|
||||
<th>Jobs</th>
|
||||
</tr>
|
||||
{#each $topJobsQuery.data.topUser as tu, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td>
|
||||
<td id="topName-jobs-{tu.id}">
|
||||
<a target="_blank" href="/monitoring/user/{tu.id}?cluster={cluster}&state=running"
|
||||
>{scrambleNames ? scramble(tu.id) : tu.id}
|
||||
</a>
|
||||
</td>
|
||||
{#if tu?.name}
|
||||
<Tooltip
|
||||
target={`topName-jobs-${tu.id}`}
|
||||
placement="left"
|
||||
>{scrambleNames ? scramble(tu.name) : tu.name}</Tooltip
|
||||
>
|
||||
{/if}
|
||||
<td>{tu['totalJobs']}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
</Col>
|
||||
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<h4 class="text-center">
|
||||
Top Projects: Jobs
|
||||
</h4>
|
||||
<Pie
|
||||
{useAltColors}
|
||||
canvasId="hpcpie-jobs-projects"
|
||||
size={colWidthJobs * 0.75}
|
||||
sliceLabel={'Jobs'}
|
||||
quantities={$topJobsQuery.data.topProjects.map(
|
||||
(tp) => tp['totalJobs'],
|
||||
)}
|
||||
entities={$topJobsQuery.data.topProjects.map((tp) => scrambleNames ? scramble(tp.id) : tp.id)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th></th>
|
||||
<th style="padding-left: 0.5rem;">Project</th>
|
||||
<th>Jobs</th>
|
||||
</tr>
|
||||
{#each $topJobsQuery.data.topProjects as tp, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td>
|
||||
<td>
|
||||
<a target="_blank" href="/monitoring/jobs/?cluster={cluster}&state=running&project={tp.id}&projectMatch=eq"
|
||||
>{scrambleNames ? scramble(tp.id) : tp.id}
|
||||
</a>
|
||||
</td>
|
||||
<td>{tp['totalJobs']}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
<Card class="mx-4" body color="warning">Cannot render job status charts: No data!</Card>
|
||||
{/if}
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- Node Distribution, Top Users and Projects-->
|
||||
{#if $topNodesQuery.fetching || $nodeStatusQuery.fetching}
|
||||
<Spinner />
|
||||
{:else if $topNodesQuery.data && $nodeStatusQuery.data}
|
||||
<Row>
|
||||
<Col xs="12" lg="4" class="p-2">
|
||||
<Histogram
|
||||
data={convert2uplot($nodeStatusQuery.data.jobsStatistics[0].histNumNodes)}
|
||||
title="Number of Nodes Distribution"
|
||||
xlabel="Allocated Nodes"
|
||||
xunit="Nodes"
|
||||
ylabel="Number of Jobs"
|
||||
yunit="Jobs"
|
||||
height="275"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<div bind:clientWidth={colWidthNodes}>
|
||||
<h4 class="text-center">
|
||||
Top Users: Nodes
|
||||
</h4>
|
||||
<Pie
|
||||
{useAltColors}
|
||||
canvasId="hpcpie-nodes-users"
|
||||
size={colWidthNodes * 0.75}
|
||||
sliceLabel="Nodes"
|
||||
quantities={$topNodesQuery.data.topUser.map(
|
||||
(tu) => tu['totalNodes'],
|
||||
)}
|
||||
entities={$topNodesQuery.data.topUser.map((tu) => scrambleNames ? scramble(tu.id) : tu.id)}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th></th>
|
||||
<th style="padding-left: 0.5rem;">User</th>
|
||||
<th>Nodes</th>
|
||||
</tr>
|
||||
{#each $topNodesQuery.data.topUser as tu, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td>
|
||||
<td id="topName-nodes-{tu.id}">
|
||||
<a target="_blank" href="/monitoring/user/{tu.id}?cluster={cluster}&state=running"
|
||||
>{scrambleNames ? scramble(tu.id) : tu.id}
|
||||
</a>
|
||||
</td>
|
||||
{#if tu?.name}
|
||||
<Tooltip
|
||||
target={`topName-nodes-${tu.id}`}
|
||||
placement="left"
|
||||
>{scrambleNames ? scramble(tu.name) : tu.name}</Tooltip
|
||||
>
|
||||
{/if}
|
||||
<td>{tu['totalNodes']}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
</Col>
|
||||
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<h4 class="text-center">
|
||||
Top Projects: Nodes
|
||||
</h4>
|
||||
<Pie
|
||||
{useAltColors}
|
||||
canvasId="hpcpie-nodes-projects"
|
||||
size={colWidthNodes * 0.75}
|
||||
sliceLabel={'Nodes'}
|
||||
quantities={$topNodesQuery.data.topProjects.map(
|
||||
(tp) => tp['totalNodes'],
|
||||
)}
|
||||
entities={$topNodesQuery.data.topProjects.map((tp) => scrambleNames ? scramble(tp.id) : tp.id)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th></th>
|
||||
<th style="padding-left: 0.5rem;">Project</th>
|
||||
<th>Nodes</th>
|
||||
</tr>
|
||||
{#each $topNodesQuery.data.topProjects as tp, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td>
|
||||
<td>
|
||||
<a target="_blank" href="/monitoring/jobs/?cluster={cluster}&state=running&project={tp.id}&projectMatch=eq"
|
||||
>{scrambleNames ? scramble(tp.id) : tp.id}
|
||||
</a>
|
||||
</td>
|
||||
<td>{tp['totalNodes']}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
<Card class="mx-4" body color="warning">Cannot render node status charts: No data!</Card>
|
||||
{/if}
|
||||
|
||||
<hr/>
|
||||
|
||||
<!-- Acc Distribution, Top Users and Projects-->
|
||||
{#if $topAccsQuery.fetching || $nodeStatusQuery.fetching}
|
||||
<Spinner />
|
||||
{:else if $topAccsQuery.data && $nodeStatusQuery.data}
|
||||
<Row>
|
||||
<Col xs="12" lg="4" class="p-2">
|
||||
<Histogram
|
||||
data={convert2uplot($nodeStatusQuery.data.jobsStatistics[0].histNumAccs)}
|
||||
title="Number of Accelerators Distribution"
|
||||
xlabel="Allocated Accs"
|
||||
xunit="Accs"
|
||||
ylabel="Number of Jobs"
|
||||
yunit="Jobs"
|
||||
height="275"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<div bind:clientWidth={colWidthAccs}>
|
||||
<h4 class="text-center">
|
||||
Top Users: GPUs
|
||||
</h4>
|
||||
<Pie
|
||||
{useAltColors}
|
||||
canvasId="hpcpie-accs-users"
|
||||
size={colWidthAccs * 0.75}
|
||||
sliceLabel="GPUs"
|
||||
quantities={$topAccsQuery.data.topUser.map(
|
||||
(tu) => tu['totalAccs'],
|
||||
)}
|
||||
entities={$topAccsQuery.data.topUser.map((tu) => scrambleNames ? scramble(tu.id) : tu.id)}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th></th>
|
||||
<th style="padding-left: 0.5rem;">User</th>
|
||||
<th>GPUs</th>
|
||||
</tr>
|
||||
{#each $topAccsQuery.data.topUser as tu, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td>
|
||||
<td id="topName-accs-{tu.id}">
|
||||
<a target="_blank" href="/monitoring/user/{tu.id}?cluster={cluster}&state=running"
|
||||
>{scrambleNames ? scramble(tu.id) : tu.id}
|
||||
</a>
|
||||
</td>
|
||||
{#if tu?.name}
|
||||
<Tooltip
|
||||
target={`topName-accs-${tu.id}`}
|
||||
placement="left"
|
||||
>{scrambleNames ? scramble(tu.name) : tu.name}</Tooltip
|
||||
>
|
||||
{/if}
|
||||
<td>{tu['totalAccs']}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
</Col>
|
||||
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<h4 class="text-center">
|
||||
Top Projects: GPUs
|
||||
</h4>
|
||||
<Pie
|
||||
{useAltColors}
|
||||
canvasId="hpcpie-accs-projects"
|
||||
size={colWidthAccs * 0.75}
|
||||
sliceLabel={'GPUs'}
|
||||
quantities={$topAccsQuery.data.topProjects.map(
|
||||
(tp) => tp['totalAccs'],
|
||||
)}
|
||||
entities={$topAccsQuery.data.topProjects.map((tp) => scrambleNames ? scramble(tp.id) : tp.id)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="6" md="3" lg="2" class="p-2">
|
||||
<Table>
|
||||
<tr class="mb-2">
|
||||
<th></th>
|
||||
<th style="padding-left: 0.5rem;">Project</th>
|
||||
<th>GPUs</th>
|
||||
</tr>
|
||||
{#each $topAccsQuery.data.topProjects as tp, i}
|
||||
<tr>
|
||||
<td><Icon name="circle-fill" style="color: {legendColors(i)};" /></td>
|
||||
<td>
|
||||
<a target="_blank" href="/monitoring/jobs/?cluster={cluster}&state=running&project={tp.id}&projectMatch=eq"
|
||||
>{scrambleNames ? scramble(tp.id) : tp.id}
|
||||
</a>
|
||||
</td>
|
||||
<td>{tp['totalAccs']}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</Table>
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
<Card class="mx-4" body color="warning">Cannot render accelerator status charts: No data!</Card>
|
||||
{/if}
|
Reference in New Issue
Block a user