Merge branch 'dev' into port-to-cclib

This commit is contained in:
Jan Eitzinger 2025-06-30 12:09:28 +02:00
commit 64da28e814
18 changed files with 566 additions and 567 deletions

View File

@ -562,8 +562,20 @@
</Row>
<Row>
<Col>
<!-- Note: Ignore '#snippet' Error in IDE -->
{#snippet histoGridContent(item)}
<Histogram
data={convert2uplot(item.bins)}
usesBins={true}
title="Average Distribution of '{item.metric}'"
xlabel={`${item.metric} bin maximum [${metricUnits[item.metric]}]`}
xunit={`${metricUnits[item.metric]}`}
ylabel="Normalized Hours"
yunit="Hours"
/>
{/snippet}
<PlotGrid
let:item
items={metricsInHistograms.map((metric) => ({
metric,
...binsFromFootprint(
@ -576,17 +588,8 @@
),
}))}
itemsPerRow={ccconfig.plot_view_plotsPerRow}
>
<Histogram
data={convert2uplot(item.bins)}
usesBins={true}
title="Average Distribution of '{item.metric}'"
xlabel={`${item.metric} bin maximum [${metricUnits[item.metric]}]`}
xunit={`${metricUnits[item.metric]}`}
ylabel="Normalized Hours"
yunit="Hours"
/>
</PlotGrid>
gridContent={histoGridContent}
/>
</Col>
</Row>
<br />
@ -604,9 +607,19 @@
</Row>
<Row>
<Col>
{#snippet metricsGridContent(item)}
<ScatterPlot
height={250}
color={"rgba(0, 102, 204, 0.33)"}
xLabel={`${item.m1} [${metricUnits[item.m1]}]`}
yLabel={`${item.m2} [${metricUnits[item.m2]}]`}
X={item.f1}
Y={item.f2}
S={$footprintsQuery.data.footprints.timeWeights.nodeHours}
/>
{/snippet}
<PlotGrid
let:item
let:width
items={metricsInScatterplots.map(([m1, m2]) => ({
m1,
f1: $footprintsQuery.data.footprints.metrics.find(
@ -618,18 +631,8 @@
).data,
}))}
itemsPerRow={ccconfig.plot_view_plotsPerRow}
>
<ScatterPlot
{width}
height={250}
color={"rgba(0, 102, 204, 0.33)"}
xLabel={`${item.m1} [${metricUnits[item.m1]}]`}
yLabel={`${item.m2} [${metricUnits[item.m2]}]`}
X={item.f1}
Y={item.f2}
S={$footprintsQuery.data.footprints.timeWeights.nodeHours}
/>
</PlotGrid>
gridContent={metricsGridContent}
/>
</Col>
</Row>
{/if}

View File

@ -9,10 +9,11 @@
-->
<script>
import { getContext } from "svelte";
import {
queryStore,
gql,
getContextClient
getContextClient,
} from "@urql/svelte";
import {
Row,
@ -26,7 +27,6 @@
CardTitle,
Button,
} from "@sveltestrap/sveltestrap";
import { getContext } from "svelte";
import {
init,
groupByScope,
@ -42,89 +42,131 @@
import PlotGrid from "./generic/PlotGrid.svelte";
import StatsTab from "./job/StatsTab.svelte";
export let dbid;
export let username;
export let authlevel;
export let roles;
// Setup General
const ccconfig = getContext("cc-config")
let isMetricsSelectionOpen = false,
selectedMetrics = [],
selectedScopes = [],
plots = {};
let totalMetrics = 0;
let missingMetrics = [],
missingHosts = [],
somethingMissing = false;
// Setup GQL
// First: Add Job Query to init function -> Only requires DBID as argument, received via URL-ID
// Second: Trigger jobMetrics query with now received jobInfos (scopes: from job metadata, selectedMetrics: from config or all, job: from url-id)
/* Svelte 5 Props */
let {
dbid,
username,
authlevel,
roles
} = $props();
/* Const Init */
// Important: init() needs to be first const declaration or contextclient will not be initialized before "const client = ..."
const { query: initq } = init(`
job(id: "${dbid}") {
id, jobId, user, project, cluster, startTime,
duration, numNodes, numHWThreads, numAcc, energy,
SMT, exclusive, partition, subCluster, arrayJobId,
monitoringStatus, state, walltime,
tags { id, type, scope, name },
resources { hostname, hwthreads, accelerators },
metaData,
userData { name, email },
concurrentJobs { items { id, jobId }, count, listQuery },
footprint { name, stat, value },
energyFootprint { hardware, metric, value }
}
`);
job(id: "${dbid}") {
id, jobId, user, project, cluster, startTime,
duration, numNodes, numHWThreads, numAcc, energy,
SMT, exclusive, partition, subCluster, arrayJobId,
monitoringStatus, state, walltime,
tags { id, type, scope, name },
resources { hostname, hwthreads, accelerators },
metaData,
userData { name, email },
concurrentJobs { items { id, jobId }, count, listQuery },
footprint { name, stat, value },
energyFootprint { hardware, metric, value }
}
`);
const client = getContextClient();
const ccconfig = getContext("cc-config");
/* Note: Actual metric data queried in <Metric> Component, only require base infos here -> reduce backend load by requesting just stats */
const query = gql`
query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!) {
jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes) {
scopedJobStats(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes) {
name
scope
metric {
unit {
prefix
base
}
timestep
statisticsSeries {
min
mean
median
max
}
series {
hostname
id
data
statistics {
min
avg
max
}
}
stats {
hostname
}
}
}
`;
$: jobMetrics = queryStore({
client: client,
query: query,
variables: { dbid, selectedMetrics, selectedScopes },
/* State Init */
let plots = $state({});
let isMetricsSelectionOpen = $state(false);
let selectedMetrics = $state([]);
let selectedScopes = $state([]);
let totalMetrics = $state(0);
/* Derived */
const jobMetrics = $derived(queryStore({
client: client,
query: query,
variables: { dbid, selectedMetrics, selectedScopes },
})
);
const missingMetrics = $derived.by(() => {
if ($initq?.data && $jobMetrics?.data) {
let job = $initq.data.job;
let metrics = $jobMetrics.data.scopedJobStats;
let metricNames = $initq.data.globalMetrics.reduce((names, gm) => {
if (gm.availability.find((av) => av.cluster === job.cluster)) {
names.push(gm.name);
}
return names;
}, []);
return metricNames.filter(
(metric) =>
!metrics.some((jm) => jm.name == metric) &&
selectedMetrics.includes(metric) &&
!checkMetricDisabled(
metric,
$initq.data.job.cluster,
$initq.data.job.subCluster,
),
);
} else {
return []
}
});
// Handle Job Query on Init -> is not executed anymore
getContext("on-init")(() => {
const missingHosts = $derived.by(() => {
if ($initq?.data && $jobMetrics?.data) {
let job = $initq.data.job;
let metrics = $jobMetrics.data.scopedJobStats;
let metricNames = $initq.data.globalMetrics.reduce((names, gm) => {
if (gm.availability.find((av) => av.cluster === job.cluster)) {
names.push(gm.name);
}
return names;
}, []);
return job.resources
.map(({ hostname }) => ({
hostname: hostname,
metrics: metricNames.filter(
(metric) =>
!metrics.some(
(jm) =>
jm.scope == "node" &&
jm.stats.some((s) => s.hostname == hostname),
),
),
}))
.filter(({ metrics }) => metrics.length > 0);
} else {
return [];
}
});
const somethingMissing = $derived(missingMetrics?.length > 0 || missingHosts?.length > 0);
/* Effects */
$effect(() => {
document.title = $initq.fetching
? "Loading..."
: $initq.error
? "Error"
: `Job ${$initq.data.job.jobId} - ClusterCockpit`;
});
/* On Init */
getContext("on-init")(() => {
let job = $initq.data.job;
if (!job) return;
const pendingMetrics = (
ccconfig[`job_view_selectedMetrics:${job.cluster}:${job.subCluster}`] ||
ccconfig[`job_view_selectedMetrics:${job.cluster}`]
@ -154,52 +196,7 @@
selectedScopes = [...new Set(pendingScopes)];
});
// Interactive Document Title
$: document.title = $initq.fetching
? "Loading..."
: $initq.error
? "Error"
: `Job ${$initq.data.job.jobId} - ClusterCockpit`;
// Find out what metrics or hosts are missing:
$: if ($initq?.data && $jobMetrics?.data?.jobMetrics) {
let job = $initq.data.job,
metrics = $jobMetrics.data.jobMetrics,
metricNames = $initq.data.globalMetrics.reduce((names, gm) => {
if (gm.availability.find((av) => av.cluster === job.cluster)) {
names.push(gm.name);
}
return names;
}, []);
// Metric not found in JobMetrics && Metric not explicitly disabled in config or deselected: Was expected, but is Missing
missingMetrics = metricNames.filter(
(metric) =>
!metrics.some((jm) => jm.name == metric) &&
selectedMetrics.includes(metric) &&
!checkMetricDisabled(
metric,
$initq.data.job.cluster,
$initq.data.job.subCluster,
),
);
missingHosts = job.resources
.map(({ hostname }) => ({
hostname: hostname,
metrics: metricNames.filter(
(metric) =>
!metrics.some(
(jm) =>
jm.scope == "node" &&
jm.metric.series.some((series) => series.hostname == hostname),
),
),
}))
.filter(({ metrics }) => metrics.length > 0);
somethingMissing = missingMetrics.length > 0 || missingHosts.length > 0;
}
// Helper
/* Functions */
const orderAndMap = (grouped, selectedMetrics) =>
selectedMetrics.map((metric) => ({
metric: metric,
@ -293,7 +290,7 @@
<Row class="mb-2">
{#if $initq?.data}
<Col xs="auto">
<Button outline on:click={() => (isMetricsSelectionOpen = true)} color="primary">
<Button outline onclick={() => (isMetricsSelectionOpen = true)} color="primary">
Select Metrics (Selected {selectedMetrics.length} of {totalMetrics} available)
</Button>
</Col>
@ -317,15 +314,9 @@
<Spinner secondary />
</Col>
</Row>
{:else if $initq?.data && $jobMetrics?.data?.jobMetrics}
<PlotGrid
let:item
items={orderAndMap(
groupByScope($jobMetrics.data.jobMetrics),
selectedMetrics,
)}
itemsPerRow={ccconfig.plot_view_plotsPerRow}
>
{:else if $initq?.data && $jobMetrics?.data?.scopedJobStats}
<!-- Note: Ignore '#snippet' Error in IDE -->
{#snippet gridContent(item)}
{#if item.data}
<Metric
bind:this={plots[item.metric]}
@ -333,8 +324,7 @@
metricName={item.metric}
metricUnit={$initq.data.globalMetrics.find((gm) => gm.name == item.metric)?.unit}
nativeScope={$initq.data.globalMetrics.find((gm) => gm.name == item.metric)?.scope}
rawData={item.data.map((x) => x.metric)}
scopes={item.data.map((x) => x.scope)}
presetScopes={item.data.map((x) => x.scope)}
isShared={$initq.data.job.exclusive != 1}
/>
{:else if item.disabled == true}
@ -357,7 +347,16 @@
</CardBody>
</Card>
{/if}
</PlotGrid>
{/snippet}
<PlotGrid
items={orderAndMap(
groupByScope($jobMetrics.data.scopedJobStats),
selectedMetrics,
)}
itemsPerRow={ccconfig.plot_view_plotsPerRow}
{gridContent}
/>
{/if}
</CardBody>
</Card>

View File

@ -178,18 +178,18 @@
{#if !showCompare}
<JobList
bind:this={jobList}
bind:metrics
bind:sorting
bind:matchedListJobs
bind:showFootprint
bind:selectedJobs
{metrics}
{sorting}
{showFootprint}
{filterBuffer}
/>
{:else}
<JobCompare
bind:this={jobCompare}
bind:metrics
bind:matchedCompareJobs
{metrics}
{filterBuffer}
/>
{/if}
@ -201,7 +201,8 @@
presetSorting={sorting}
applySorting={(newSort) =>
sorting = {...newSort}
}/>
}
/>
<MetricSelection
bind:isOpen={isMetricsSelectionOpen}

View File

@ -204,20 +204,8 @@
{:else if $nodeMetricsData.fetching || $initq.fetching}
<Spinner />
{:else}
<PlotGrid
let:item
itemsPerRow={ccconfig.plot_view_plotsPerRow}
items={$nodeMetricsData.data.nodeMetrics[0].metrics
.map((m) => ({
...m,
disabled: checkMetricDisabled(
m.name,
cluster,
$nodeMetricsData.data.nodeMetrics[0].subCluster,
),
}))
.sort((a, b) => a.name.localeCompare(b.name))}
>
<!-- Note: Ignore '#snippet' Error in IDE -->
{#snippet gridContent(item)}
<h4 style="text-align: center; padding-top:15px;">
{item.name}
{systemUnits[item.name] ? "(" + systemUnits[item.name] + ")" : ""}
@ -246,7 +234,22 @@
>No dataset returned for <code>{item.name}</code></Card
>
{/if}
</PlotGrid>
{/snippet}
<PlotGrid
items={$nodeMetricsData.data.nodeMetrics[0].metrics
.map((m) => ({
...m,
disabled: checkMetricDisabled(
m.name,
cluster,
$nodeMetricsData.data.nodeMetrics[0].subCluster,
),
}))
.sort((a, b) => a.name.localeCompare(b.name))}
itemsPerRow={ccconfig.plot_view_plotsPerRow}
{gridContent}
/>
{/if}
</Col>
</Row>

View File

@ -676,22 +676,25 @@
<!-- 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)}
usesBins={true}
title="Distribution of '{item.metric}' averages"
xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`}
xunit={item.unit}
ylabel="Number of Jobs"
yunit="Jobs"
/>
{/snippet}
{#key $mainQuery.data.stats[0].histMetrics}
<PlotGrid
let:item
items={$mainQuery.data.stats[0].histMetrics}
itemsPerRow={2}
>
<Histogram
data={convert2uplot(item.data)}
usesBins={true}
title="Distribution of '{item.metric}' averages"
xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`}
xunit={item.unit}
ylabel="Number of Jobs"
yunit="Jobs"
/>
</PlotGrid>
{gridContent}
/>
{/key}
{/if}
{/if}

View File

@ -335,22 +335,25 @@
</Row>
{:else}
<hr class="my-2"/>
<!-- Note: Ignore '#snippet' Error in IDE -->
{#snippet gridContent(item)}
<Histogram
data={convert2uplot(item.data)}
title="Distribution of '{item.metric} ({item.stat})' footprints"
xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`}
xunit={item.unit}
ylabel="Number of Jobs"
yunit="Jobs"
usesBins
/>
{/snippet}
{#key $stats.data.jobsStatistics[0].histMetrics}
<PlotGrid
let:item
items={$stats.data.jobsStatistics[0].histMetrics}
itemsPerRow={3}
>
<Histogram
data={convert2uplot(item.data)}
title="Distribution of '{item.metric} ({item.stat})' footprints"
xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`}
xunit={item.unit}
ylabel="Number of Jobs"
yunit="Jobs"
usesBins
/>
</PlotGrid>
{gridContent}
/>
{/key}
{/if}
{:else}
@ -367,9 +370,9 @@
<JobList
bind:this={jobList}
bind:matchedListJobs
bind:metrics
bind:sorting
bind:showFootprint
{metrics}
{sorting}
{showFootprint}
/>
</Col>
</Row>
@ -388,10 +391,10 @@
presetMetrics={metrics}
cluster={selectedCluster}
configName="plot_list_selectedMetrics"
footprintSelect
applyMetrics={(newMetrics) =>
metrics = [...newMetrics]
}
footprintSelect
/>
<HistogramSelection

View File

@ -26,7 +26,7 @@
/* Svelte 5 Props */
let {
matchedCompareJobs = $bindable(0),
metrics = $bindable(ccconfig?.plot_list_selectedMetrics),
metrics = ccconfig?.plot_list_selectedMetrics,
filterBuffer = [],
} = $props();

View File

@ -14,7 +14,7 @@
-->
<script>
import { getContext } from "svelte";
import { getContext, untrack } from "svelte";
import {
queryStore,
gql,
@ -26,35 +26,22 @@
import Pagination from "./joblist/Pagination.svelte";
import JobListRow from "./joblist/JobListRow.svelte";
const ccconfig = getContext("cc-config"),
initialized = getContext("initialized"),
globalMetrics = getContext("globalMetrics");
const equalsCheck = (a, b) => {
return JSON.stringify(a) === JSON.stringify(b);
}
export let sorting = { field: "startTime", type: "col", order: "DESC" };
export let matchedListJobs = 0;
export let metrics = ccconfig.plot_list_selectedMetrics;
export let showFootprint;
export let filterBuffer = [];
export let selectedJobs = [];
let usePaging = ccconfig.job_list_usePaging
let itemsPerPage = usePaging ? ccconfig.plot_list_jobsPerPage : 10;
let page = 1;
let paging = { itemsPerPage, page };
let filter = [...filterBuffer];
let lastFilter = [];
let lastSorting = null;
let triggerMetricRefresh = false;
function getUnit(m) {
const rawUnit = globalMetrics.find((gm) => gm.name === m)?.unit
return (rawUnit?.prefix ? rawUnit.prefix : "") + (rawUnit?.base ? rawUnit.base : "")
}
/* Svelte 5 Props */
let {
matchedListJobs = $bindable(0),
selectedJobs = $bindable([]),
metrics = getContext("cc-config").plot_list_selectedMetrics,
sorting = { field: "startTime", type: "col", order: "DESC" },
showFootprint = false,
filterBuffer = [],
} = $props();
/* Const Init */
const ccconfig = getContext("cc-config");
const initialized = getContext("initialized");
const globalMetrics = getContext("globalMetrics");
const usePaging = ccconfig?.job_list_usePaging || false;
const jobInfoColumnWidth = 250;
const client = getContextClient();
const query = gql`
query (
@ -107,58 +94,75 @@
}
`;
$: jobsStore = queryStore({
client: client,
query: query,
variables: { paging, sorting, filter },
/* State Init */
let headerPaddingTop = $state(0);
let jobs = $state([]);
let filter = $state([...filterBuffer]);
let page = $state(1);
let itemsPerPage = $state(usePaging ? (ccconfig?.plot_list_jobsPerPage || 10) : 10);
let triggerMetricRefresh = $state(false);
let tableWidth = $state(0);
/* Derived */
let paging = $derived({ itemsPerPage, page });
const plotWidth = $derived.by(() => {
return Math.floor(
(tableWidth - jobInfoColumnWidth) / (metrics.length + (showFootprint ? 1 : 0)) - 10,
);
});
let jobs = [];
$: if ($initialized && $jobsStore.data) {
if (usePaging) {
jobs = [...$jobsStore.data.jobs.items]
} else { // Prevents jump to table head in continiuous mode, only if no change in sort or filter
if (equalsCheck(filter, lastFilter) && equalsCheck(sorting, lastSorting)) {
// console.log('Both Equal: Continuous Addition ... Set None')
jobs = jobs.concat([...$jobsStore.data.jobs.items])
} else if (equalsCheck(filter, lastFilter)) {
// console.log('Filter Equal: Continuous Reset ... Set lastSorting')
lastSorting = { ...sorting }
jobs = [...$jobsStore.data.jobs.items]
} else if (equalsCheck(sorting, lastSorting)) {
// console.log('Sorting Equal: Continuous Reset ... Set lastFilter')
lastFilter = [ ...filter ]
jobs = [...$jobsStore.data.jobs.items]
} else {
// console.log('None Equal: Continuous Reset ... Set lastBoth')
lastSorting = { ...sorting }
lastFilter = [ ...filter ]
jobs = [...$jobsStore.data.jobs.items]
}
}
}
$: if (!usePaging && (sorting || filter)) {
// Continous Scroll: Reset list and paging if parameters change: Existing entries will not match new selections
jobs = [];
paging = { itemsPerPage: 10, page: 1 };
}
$: matchedListJobs = $jobsStore.data != null ? $jobsStore.data.jobs.count : -1;
// Force refresh list with existing unchanged variables (== usually would not trigger reactivity)
export function refreshJobs() {
if (!usePaging) {
jobs = []; // Empty Joblist before refresh, prevents infinite buildup
paging = { itemsPerPage: 10, page: 1 };
}
jobsStore = queryStore({
let jobsStore = $derived(queryStore({
client: client,
query: query,
variables: { paging, sorting, filter },
requestPolicy: "network-only",
});
}
})
);
/* Effects */
$effect(() => {
if (!usePaging) {
window.addEventListener('scroll', () => {
let {
scrollTop,
scrollHeight,
clientHeight
} = document.documentElement;
// Add 100 px offset to trigger load earlier
if (scrollTop + clientHeight >= scrollHeight - 100 && $jobsStore?.data?.jobs?.hasNextPage) {
page += 1
};
});
};
});
$effect(() => {
//Triggers
filter
sorting
// Reset Continous Jobs
if (!usePaging) {
page = 1;
}
});
$effect(() => {
if ($jobsStore?.data) {
untrack(() => {
handleJobs($jobsStore.data.jobs.items);
});
};
});
/* Functions */
// Force refresh list with existing unchanged variables
export function refreshJobs() {
if (usePaging) {
paging = {...paging}
} else {
page = 1;
}
};
export function refreshAllMetrics() {
// Refresh Job Metrics (Downstream will only query for running jobs)
@ -175,11 +179,48 @@
if (minRunningFor && minRunningFor > 0) {
filters.push({ minRunningFor });
}
filter = filters;
filter = [...filters];
}
page = 1;
paging = paging = { page, itemsPerPage };
}
};
function handleJobs(newJobs) {
if (newJobs) {
if (usePaging) {
// console.log('New Paging', $state.snapshot(paging))
jobs = [...newJobs]
} else {
if ($state.snapshot(page) == 1) {
// console.log('Page 1 Reset', [...newJobs])
jobs = [...newJobs]
} else {
// console.log('Add Jobs', $state.snapshot(jobs), [...newJobs])
jobs = jobs.concat([...newJobs])
}
}
matchedListJobs = $jobsStore.data.jobs.count;
} else {
matchedListJobs = -1
}
};
function updateConfiguration(value, newPage) {
updateConfigurationMutation({
name: "plot_list_jobsPerPage",
value: value.toString(),
}).subscribe((res) => {
if (res.fetching === false && !res.error) {
itemsPerPage = value
page = newPage // Trigger reload of jobList
} else if (res.fetching === false && res.error) {
throw res.error;
}
});
};
function getUnit(m) {
const rawUnit = globalMetrics.find((gm) => gm.name === m)?.unit
return (rawUnit?.prefix ? rawUnit.prefix : "") + (rawUnit?.base ? rawUnit.base : "")
};
const updateConfigurationMutation = ({ name, value }) => {
return mutationStore({
@ -193,52 +234,11 @@
});
};
function updateConfiguration(value, page) {
updateConfigurationMutation({
name: "plot_list_jobsPerPage",
value: value,
}).subscribe((res) => {
if (res.fetching === false && !res.error) {
jobs = [] // Empty List
paging = { itemsPerPage: value, page: page }; // Trigger reload of jobList
} else if (res.fetching === false && res.error) {
throw res.error;
}
});
const equalsCheck = (a, b) => {
return JSON.stringify(a) === JSON.stringify(b);
}
if (!usePaging) {
window.addEventListener('scroll', () => {
let {
scrollTop,
scrollHeight,
clientHeight
} = document.documentElement;
// Add 100 px offset to trigger load earlier
if (scrollTop + clientHeight >= scrollHeight - 100 && $jobsStore.data != null && $jobsStore.data.jobs.hasNextPage) {
let pendingPaging = { ...paging }
pendingPaging.page += 1
paging = pendingPaging
};
});
};
let plotWidth = null;
let tableWidth = null;
let jobInfoColumnWidth = 250;
$: if (showFootprint) {
plotWidth = Math.floor(
(tableWidth - jobInfoColumnWidth) / (metrics.length + 1) - 10,
);
} else {
plotWidth = Math.floor(
(tableWidth - jobInfoColumnWidth) / metrics.length - 10,
);
}
let headerPaddingTop = 0;
/* Init Header */
stickyHeader(
".cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)",
(x) => (headerPaddingTop = x),
@ -291,9 +291,9 @@
</tr>
{:else}
{#each jobs as job (job.id)}
<JobListRow bind:triggerMetricRefresh {job} {metrics} {plotWidth} {showFootprint} previousSelect={selectedJobs.includes(job.id)}
on:select-job={({detail}) => selectedJobs = [...selectedJobs, detail]}
on:unselect-job={({detail}) => selectedJobs = selectedJobs.filter(item => item !== detail)}
<JobListRow {triggerMetricRefresh} {job} {metrics} {plotWidth} {showFootprint} previousSelect={selectedJobs.includes(job.id)}
selectJob={(detail) => selectedJobs = [...selectedJobs, detail]}
unselectJob={(detail) => selectedJobs = selectedJobs.filter(item => item !== detail)}
/>
{:else}
<tr>
@ -323,10 +323,10 @@
totalItems={matchedListJobs}
updatePaging={(detail) => {
if (detail.itemsPerPage != itemsPerPage) {
updateConfiguration(detail.itemsPerPage.toString(), detail.page);
updateConfiguration(detail.itemsPerPage, detail.page);
} else {
jobs = []
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page };
itemsPerPage = detail.itemsPerPage
page = detail.page
}
}}
/>

View File

@ -6,32 +6,25 @@
- `items [Any]`: List of plot components to render
-->
<script>
import {
Row,
Col,
} from "@sveltestrap/sveltestrap";
<script>
import {
Row,
Col,
} from "@sveltestrap/sveltestrap";
export let itemsPerRow
export let items
/* Migtation Notes
* Requirements
* - Parent Components must be already Migrated
* - TODO: Job.root.svelte, Node.root.svelte
* - DONE: Analysis, Status, User
*
* How-To
* - Define "Plot-Slotcode" as SV5 Snippet with argument "item" in parent (!)
* - Pass new snippet as argument/prop to here
* - @render snippet in items-loop with argument == item
*/
/* Svelte 5 Props */
let {
items,
itemsPerRow,
gridContent
} = $props();
</script>
<Row cols={{ xs: 1, sm: 2, md: 3, lg: itemsPerRow}}>
{#each items as item}
<Col class="px-1">
<slot {item}/>
<!-- Note: Ignore '@' Error in IDE -->
{@render gridContent(item)}
</Col>
{/each}
</Row>

View File

@ -20,7 +20,7 @@
username = null,
authlevel= null,
roles = null,
isSelected = null,
isSelected = $bindable(),
showSelect = false,
} = $props();
@ -89,10 +89,8 @@
}}>
{#if isSelected}
<Icon name="check-square"/>
{:else if isSelected == false}
<Icon name="square"/>
{:else}
<Icon name="plus-square-dotted" />
{:else }
<Icon name="plus-square-dotted"/>
{/if}
</Button>
<Tooltip

View File

@ -12,39 +12,37 @@
<script>
import { queryStore, gql, getContextClient } from "@urql/svelte";
import { getContext, createEventDispatcher } from "svelte";
import { getContext } from "svelte";
import { Card, Spinner } from "@sveltestrap/sveltestrap";
import { maxScope, checkMetricDisabled } from "../utils.js";
import JobInfo from "./JobInfo.svelte";
import MetricPlot from "../plots/MetricPlot.svelte";
import JobFootprint from "../helper/JobFootprint.svelte";
export let job;
export let metrics;
export let plotWidth;
export let plotHeight = 275;
export let showFootprint;
export let triggerMetricRefresh = false;
export let previousSelect = false;
/* Svelte 5 Props */
let {
triggerMetricRefresh = false,
job,
metrics,
plotWidth,
plotHeight = 275,
showFootprint,
previousSelect = false,
selectJob,
unselectJob
} = $props();
const dispatch = createEventDispatcher();
const resampleConfig = getContext("resampling") || null;
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
let { id } = job;
let scopes = job.numNodes == 1
? job.numAcc >= 1
/* Const Init */
const client = getContextClient();
const jobId = job.id;
const cluster = getContext("clusters");
const scopes = (job.numNodes == 1)
? (job.numAcc >= 1)
? ["core", "accelerator"]
: ["core"]
: ["node"];
let selectedResolution = resampleDefault;
let zoomStates = {};
let thresholdStates = {};
$: isSelected = previousSelect || null;
const cluster = getContext("clusters").find((c) => c.name == job.cluster);
const client = getContextClient();
const resampleConfig = getContext("resampling") || null;
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
const query = gql`
query ($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!, $selectedResolution: Int) {
jobMetrics(id: $id, metrics: $metrics, scopes: $scopes, resolution: $selectedResolution) {
@ -77,52 +75,59 @@
}
`;
function handleZoom(detail, metric) {
if ( // States have to differ, causes deathloop if just set
(zoomStates[metric]?.x?.min !== detail?.lastZoomState?.x?.min) &&
(zoomStates[metric]?.y?.max !== detail?.lastZoomState?.y?.max)
) {
zoomStates[metric] = {...detail.lastZoomState}
/* State Init */
let selectedResolution = $state(resampleDefault);
let zoomStates = $state({});
let thresholdStates = $state({});
/* Derived */
let isSelected = $derived(previousSelect);
let metricsQuery = $derived(queryStore({
client: client,
query: query,
variables: { id: jobId, metrics, scopes, selectedResolution },
})
);
/* Effects */
$effect(() => {
if (job.state === 'running' && triggerMetricRefresh === true) {
refreshMetrics();
}
});
if ( // States have to differ, causes deathloop if just set
detail?.lastThreshold &&
thresholdStates[metric] !== detail.lastThreshold
) { // Handle to correctly reset on summed metric scope change
thresholdStates[metric] = detail.lastThreshold;
}
$effect(() => {
if (isSelected == true && previousSelect == false) {
selectJob(jobId)
} else if (isSelected == false && previousSelect == true) {
unselectJob(jobId)
}
});
if (detail?.newRes) { // Triggers GQL
selectedResolution = detail.newRes
/* Functions */
function handleZoom(detail, metric) {
// Buffer last zoom state to allow seamless zoom on rerender
// console.log('Update zoomState for/with:', metric, {...detail.lastZoomState})
zoomStates[metric] = detail?.lastZoomState ? {...detail.lastZoomState} : null;
// Handle to correctly reset on summed metric scope change
// console.log('Update thresholdState for/with:', metric, detail.lastThreshold)
thresholdStates[metric] = detail?.lastThreshold ? detail.lastThreshold : null;
// Triggers GQL
if (detail?.newRes) {
// console.log('Update selectedResolution for/with:', metric, detail.newRes)
selectedResolution = detail.newRes;
}
}
$: metricsQuery = queryStore({
client: client,
query: query,
variables: { id, metrics, scopes, selectedResolution },
});
function refreshMetrics() {
metricsQuery = queryStore({
client: client,
query: query,
variables: { id, metrics, scopes, selectedResolution },
variables: { id: jobId, metrics, scopes, selectedResolution },
// requestPolicy: 'network-only' // use default cache-first for refresh
});
}
$: if (job.state === 'running' && triggerMetricRefresh === true) {
refreshMetrics();
}
$: if (isSelected == true && previousSelect == false) {
dispatch("select-job", job.id)
} else if (isSelected == false && previousSelect == true) {
dispatch("unselect-job", job.id)
}
// Helper
const selectScope = (jobMetrics) =>
jobMetrics.reduce(
(a, b) =>
@ -146,6 +151,7 @@
.map((jobMetric) => {
if (jobMetric.data) {
return {
name: jobMetric.data.name,
disabled: checkMetricDisabled(
jobMetric.data.name,
job.cluster,
@ -157,7 +163,6 @@
return jobMetric;
}
});
</script>
<tr>
@ -191,19 +196,19 @@
/>
</td>
{/if}
{#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric || i)}
{#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric?.name || i)}
<td>
<!-- Subluster Metricconfig remove keyword for jobtables (joblist main, user joblist, project joblist) to be used here as toplevel case-->
{#if metric.disabled == false && metric.data}
<MetricPlot
on:zoom={({detail}) => { handleZoom(detail, metric.data.name) }}
on:zoom={({detail}) => handleZoom(detail, metric.data.name)}
height={plotHeight}
timestep={metric.data.metric.timestep}
scope={metric.data.scope}
series={metric.data.metric.series}
statisticsSeries={metric.data.metric.statisticsSeries}
metric={metric.data.name}
{cluster}
cluster={cluster.find((c) => c.name == job.cluster)}
subCluster={job.subCluster}
isShared={job.exclusive != 1}
numhwthreads={job.numHWThreads}

View File

@ -440,7 +440,7 @@
/* IF Zoom Enabled */
if (resampleConfig) {
u.over.addEventListener("dblclick", (e) => {
// console.log('Dispatch Reset')
// console.log('Dispatch: Zoom Reset')
dispatch('zoom', {
lastZoomState: {
x: { time: false },
@ -506,7 +506,7 @@
});
// Prevents non-required dispatches
if (timestep !== closest) {
// console.log('Dispatch Zoom with Res from / to', timestep, closest)
// console.log('Dispatch: Zoom with Res from / to', timestep, closest)
dispatch('zoom', {
newRes: closest,
lastZoomState: u?.scales,
@ -514,6 +514,7 @@
});
}
} else {
// console.log('Dispatch: Zoom Update States')
dispatch('zoom', {
lastZoomState: u?.scales,
lastThreshold: thresholds?.normal

View File

@ -182,6 +182,6 @@
</script>
<div class="cc-plot">
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
<div class="cc-plot" bind:clientWidth={width}>
<canvas bind:this={canvasElement} {width} {height}></canvas>
</div>

View File

@ -18,6 +18,7 @@ Changes #2: Rewritten for Svelte 5, removed bodyHandler
-->
<script>
/* Svelte 5 Props */
let {
sliderMin,
sliderMax,
@ -26,6 +27,7 @@ Changes #2: Rewritten for Svelte 5, removed bodyHandler
changeRange
} = $props();
/* State Init */
let pendingValues = $state([fromPreset, toPreset]);
let sliderFrom = $state(Math.max(((fromPreset == null ? sliderMin : fromPreset) - sliderMin) / (sliderMax - sliderMin), 0.));
let sliderTo = $state(Math.min(((toPreset == null ? sliderMin : toPreset) - sliderMin) / (sliderMax - sliderMin), 1.));
@ -34,7 +36,10 @@ Changes #2: Rewritten for Svelte 5, removed bodyHandler
let leftHandle = $state();
let sliderMain = $state();
/* Var Init */
let timeoutId = null;
/* Functions */
function queueChangeEvent() {
if (timeoutId !== null) {
clearTimeout(timeoutId)

View File

@ -31,33 +31,22 @@
} from "../generic/utils.js";
import Timeseries from "../generic/plots/MetricPlot.svelte";
export let job;
export let metricName;
export let metricUnit;
export let nativeScope;
export let scopes;
export let rawData;
export let isShared = false;
/* Svelte 5 Props */
let {
job,
metricName,
metricUnit,
nativeScope,
presetScopes,
isShared = false,
} = $props();
/* Const Init */
const client = getContextClient();
const statsPattern = /(.*)-stat$/;
const resampleConfig = getContext("resampling") || null;
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
let selectedHost = null;
let error = null;
let selectedScope = minScope(scopes);
let selectedResolution = null;
let pendingResolution = resampleDefault;
let selectedScopeIndex = scopes.findIndex((s) => s == minScope(scopes));
let patternMatches = false;
let nodeOnly = false; // If, after load-all, still only node scope returned
let statsSeries = rawData.map((data) => data?.statisticsSeries ? data.statisticsSeries : null);
let zoomState = null;
let pendingZoomState = null;
let thresholdState = null;
const statsPattern = /(.*)-stat$/;
const unit = (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : "");
const client = getContextClient();
const subQuery = gql`
query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!, $selectedResolution: Int) {
singleUpdate: jobMetrics(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes, resolution: $selectedResolution) {
@ -90,84 +79,68 @@
}
`;
function handleZoom(detail) {
if ( // States have to differ, causes deathloop if just set
(pendingZoomState?.x?.min !== detail?.lastZoomState?.x?.min) &&
(pendingZoomState?.y?.max !== detail?.lastZoomState?.y?.max)
) {
pendingZoomState = {...detail.lastZoomState};
}
/* State Init */
let requestedScopes = $state(presetScopes);
let selectedResolution = $state(resampleDefault);
if (detail?.lastThreshold) { // Handle to correctly reset on summed metric scope change
thresholdState = detail.lastThreshold;
} else {
thresholdState = null;
}
let selectedHost = $state(null);
let zoomState = $state(null);
let thresholdState = $state(null);
if (detail?.newRes) { // Triggers GQL
pendingResolution = detail.newRes;
}
};
let metricData;
let selectedScopes = [...scopes];
const dbid = job.id;
const selectedMetrics = [metricName];
$: if (selectedScope || pendingResolution) {
if (resampleConfig && !selectedResolution) {
// Skips reactive data load on init || Only if resampling is enabled
selectedResolution = Number(pendingResolution)
} else {
if (selectedScope == "load-all") {
selectedScopes = [...scopes, "socket", "core", "accelerator"]
}
if (resampleConfig && pendingResolution) {
selectedResolution = Number(pendingResolution)
}
metricData = queryStore({
client: client,
query: subQuery,
variables: { dbid, selectedMetrics, selectedScopes, selectedResolution: (resampleConfig ? selectedResolution : 0) },
// Never user network-only: causes reactive load-loop!
});
if ($metricData && !$metricData.fetching) {
rawData = $metricData.data.singleUpdate.map((x) => x.metric)
scopes = $metricData.data.singleUpdate.map((x) => x.scope)
statsSeries = rawData.map((data) => data?.statisticsSeries ? data.statisticsSeries : null)
// Keep Zoomlevel if ResChange By Zoom
if (pendingZoomState) {
zoomState = {...pendingZoomState}
}
// On additional scope request
if (selectedScope == "load-all") {
// Set selected scope to min of returned scopes
selectedScope = minScope(scopes)
nodeOnly = (selectedScope == "node") // "node" still only scope after load-all
}
patternMatches = statsPattern.exec(selectedScope)
if (!patternMatches) {
selectedScopeIndex = scopes.findIndex((s) => s == selectedScope);
} else {
selectedScopeIndex = scopes.findIndex((s) => s == patternMatches[1]);
}
}
}
}
$: data = rawData[selectedScopeIndex];
$: series = data?.series?.filter(
(series) => selectedHost == null || series.hostname == selectedHost,
/* Derived */
const metricData = $derived(queryStore({
client: client,
query: subQuery,
variables: {
dbid: job.id,
selectedMetrics: [metricName],
selectedScopes: [...requestedScopes],
selectedResolution
},
// Never user network-only: causes reactive load-loop!
})
);
const rawData = $derived($metricData?.data ? $metricData.data.singleUpdate.map((x) => x.metric) : []);
const availableScopes = $derived($metricData?.data ? $metricData.data.singleUpdate.map((x) => x.scope) : presetScopes);
let selectedScope = $derived(minScope(availableScopes));
const patternMatches = $derived(statsPattern.exec(selectedScope));
const selectedScopeIndex = $derived.by(() => {
if (!patternMatches) {
return availableScopes.findIndex((s) => s == selectedScope);
} else {
return availableScopes.findIndex((s) => s == patternMatches[1]);
}
});
const selectedData = $derived(rawData[selectedScopeIndex]);
const selectedSeries = $derived(rawData[selectedScopeIndex]?.series?.filter(
(series) => selectedHost == null || series.hostname == selectedHost
)
);
const statsSeries = $derived(rawData.map((rd) => rd?.statisticsSeries ? rd.statisticsSeries : null));
/* Effect */
$effect(() => {
// Only triggered once
if (selectedScope == "load-all") {
requestedScopes = ["node", "socket", "core", "accelerator"];
}
});
/* Functions */
function handleZoom(detail) {
// Buffer last zoom state to allow seamless zoom on rerender
// console.log('Update zoomState with:', {...detail.lastZoomState})
zoomState = detail?.lastZoomState ? {...detail.lastZoomState} : null;
// Handle to correctly reset on summed metric scope change
// console.log('Update thresholdState with:', detail.lastThreshold)
thresholdState = detail?.lastThreshold ? detail.lastThreshold : null;
// Triggers GQL
if (detail?.newRes) {
// console.log('Update selectedResolution with:', detail.newRes)
selectedResolution = detail.newRes;
}
};
</script>
<InputGroup class="mt-2">
@ -175,13 +148,13 @@
{metricName} ({unit})
</InputGroupText>
<select class="form-select" bind:value={selectedScope}>
{#each scopes as scope, index}
{#each availableScopes as scope, index}
<option value={scope}>{scope}</option>
{#if statsSeries[index]}
<option value={scope + '-stat'}>stats series ({scope})</option>
{/if}
{/each}
{#if scopes.length == 1 && nativeScope != "node" && !nodeOnly}
{#if requestedScopes.length == 1 && nativeScope != "node"}
<option value={"load-all"}>Load all...</option>
{/if}
</select>
@ -194,37 +167,37 @@
</select>
{/if}
</InputGroup>
{#key series}
{#if $metricData?.fetching == true}
{#key selectedSeries}
{#if $metricData.fetching}
<Spinner />
{:else if error != null}
<Card body color="danger">{error.message}</Card>
{:else if series != null && !patternMatches}
{:else if $metricData.error}
<Card body color="danger">{$metricData.error.message}</Card>
{:else if selectedSeries != null && !patternMatches}
<Timeseries
on:zoom={({detail}) => { handleZoom(detail) }}
on:zoom={({detail}) => handleZoom(detail)}
cluster={job.cluster}
subCluster={job.subCluster}
timestep={data.timestep}
timestep={selectedData.timestep}
scope={selectedScope}
metric={metricName}
numaccs={job.numAcc}
numhwthreads={job.numHWThreads}
{series}
series={selectedSeries}
{isShared}
{zoomState}
{thresholdState}
/>
{:else if statsSeries[selectedScopeIndex] != null && patternMatches}
<Timeseries
on:zoom={({detail}) => { handleZoom(detail) }}
on:zoom={({detail}) => handleZoom(detail)}
cluster={job.cluster}
subCluster={job.subCluster}
timestep={data.timestep}
timestep={selectedData.timestep}
scope={selectedScope}
metric={metricName}
numaccs={job.numAcc}
numhwthreads={job.numHWThreads}
{series}
series={selectedSeries}
{isShared}
{zoomState}
{thresholdState}

View File

@ -108,7 +108,7 @@
}));
/* Effects */
$effect(() => {
$effect(() => {
if (!usePaging) {
window.addEventListener('scroll', () => {
let {

View File

@ -16,28 +16,37 @@
CardBody,
Input,
InputGroup,
InputGroupText, } from "@sveltestrap/sveltestrap";
InputGroupText,
} from "@sveltestrap/sveltestrap";
import {
scramble,
scrambleNames, } from "../../generic/utils.js";
scrambleNames,
} from "../../generic/utils.js";
export let cluster;
export let subCluster
export let hostname;
export let dataHealth;
export let nodeJobsData = null;
/* Svelte 5 Props */
let {
cluster,
subCluster,
hostname,
dataHealth,
nodeJobsData = null,
} = $props();
/* Const Init */
// Not at least one returned, selected metric: NodeHealth warning
const healthWarn = !dataHealth.includes(true);
// At least one non-returned selected metric: Metric config error?
const metricWarn = dataHealth.includes(false);
let userList;
let projectList;
$: if (nodeJobsData) {
userList = Array.from(new Set(nodeJobsData.jobs.items.map((j) => scrambleNames ? scramble(j.user) : j.user))).sort((a, b) => a.localeCompare(b));
projectList = Array.from(new Set(nodeJobsData.jobs.items.map((j) => scrambleNames ? scramble(j.project) : j.project))).sort((a, b) => a.localeCompare(b));
}
/* Derived */
const userList = $derived(nodeJobsData
? Array.from(new Set(nodeJobsData.jobs.items.map((j) => scrambleNames ? scramble(j.user) : j.user))).sort((a, b) => a.localeCompare(b))
: []
);
const projectList = $derived(nodeJobsData
? Array.from(new Set(nodeJobsData.jobs.items.map((j) => scrambleNames ? scramble(j.project) : j.project))).sort((a, b) => a.localeCompare(b))
: []);
</script>
<Card class="pb-3">

View File

@ -18,10 +18,14 @@
import MetricPlot from "../../generic/plots/MetricPlot.svelte";
import NodeInfo from "./NodeInfo.svelte";
export let cluster;
export let nodeData;
export let selectedMetrics;
/* Svelte 5 Props */
let {
cluster,
nodeData,
selectedMetrics,
} = $props();
/* Const Init */
const client = getContextClient();
const paging = { itemsPerPage: 50, page: 1 };
const sorting = { field: "startTime", type: "col", order: "DESC" };
@ -30,7 +34,6 @@
{ node: { contains: nodeData.host } },
{ state: ["running"] },
];
const nodeJobsQuery = gql`
query (
$filter: [JobFilter!]!
@ -53,13 +56,19 @@
}
`;
$: nodeJobsData = queryStore({
client: client,
query: nodeJobsQuery,
variables: { paging, sorting, filter },
});
/* Derived */
const nodeJobsData = $derived(queryStore({
client: client,
query: nodeJobsQuery,
variables: { paging, sorting, filter },
})
);
// Helper
let extendedLegendData = $derived($nodeJobsData?.data ? buildExtendedLegend() : null);
let refinedData = $derived(nodeData?.metrics ? sortAndSelectScope(nodeData.metrics) : null);
let dataHealth = $derived(refinedData.filter((rd) => rd.disabled === false).map((enabled) => (enabled.data.metric.series.length > 0)));
/* Functions */
const selectScope = (nodeMetrics) =>
nodeMetrics.reduce(
(a, b) =>
@ -89,15 +98,8 @@
}
});
let refinedData;
let dataHealth;
$: if (nodeData?.metrics) {
refinedData = sortAndSelectScope(nodeData?.metrics)
dataHealth = refinedData.filter((rd) => rd.disabled === false).map((enabled) => (enabled.data.metric.series.length > 0))
}
let extendedLegendData = null;
$: if ($nodeJobsData?.data) {
function buildExtendedLegend() {
let pendingExtendedLegendData = null
// Build Extended for allocated nodes [Commented: Only Build extended Legend For Shared Nodes]
if ($nodeJobsData.data.jobs.count >= 1) { // "&& !$nodeJobsData.data.jobs.items[0].exclusive)"
const accSet = Array.from(new Set($nodeJobsData.data.jobs.items
@ -107,11 +109,11 @@
)
)).flat(2)
extendedLegendData = {}
pendingExtendedLegendData = {};
for (const accId of accSet) {
const matchJob = $nodeJobsData.data.jobs.items.find((i) => i.resources.find((r) => r.accelerators.includes(accId)))
const matchUser = matchJob?.user ? matchJob.user : null
extendedLegendData[accId] = {
pendingExtendedLegendData[accId] = {
user: (scrambleNames && matchUser)
? scramble(matchUser)
: (matchUser ? matchUser : '-'),
@ -120,6 +122,7 @@
}
// Theoretically extendable for hwthreadIDs
}
return pendingExtendedLegendData;
}
</script>