add compareJobs feature to user job list view

This commit is contained in:
Christoph Kluge
2026-01-30 17:22:39 +01:00
parent 1ffcc5e241
commit aa3fcbfe17

View File

@@ -7,7 +7,7 @@
--> -->
<script> <script>
import { onMount, getContext } from "svelte"; import { untrack, onMount, getContext } from "svelte";
import { import {
Table, Table,
Row, Row,
@@ -33,6 +33,7 @@
scrambleNames, scrambleNames,
} from "./generic/utils.js"; } from "./generic/utils.js";
import JobList from "./generic/JobList.svelte"; import JobList from "./generic/JobList.svelte";
import JobCompare from "./generic/JobCompare.svelte";
import Filters from "./generic/Filters.svelte"; import Filters from "./generic/Filters.svelte";
import PlotGrid from "./generic/PlotGrid.svelte"; import PlotGrid from "./generic/PlotGrid.svelte";
import Histogram from "./generic/plots/Histogram.svelte"; import Histogram from "./generic/plots/Histogram.svelte";
@@ -54,37 +55,44 @@
const client = getContextClient(); const client = getContextClient();
const durationBinOptions = ["1m","10m","1h","6h","12h"]; const durationBinOptions = ["1m","10m","1h","6h","12h"];
const metricBinOptions = [10, 20, 50, 100]; const metricBinOptions = [10, 20, 50, 100];
const matchedJobCompareLimit = 500;
/* State Init */ /* State Init */
// List & Control Vars // List & Control Vars
let filterComponent = $state(); // see why here: https://stackoverflow.com/questions/58287729/how-can-i-export-a-function-from-a-svelte-component-that-changes-a-value-in-the let filterComponent = $state(); // see why here: https://stackoverflow.com/questions/58287729/how-can-i-export-a-function-from-a-svelte-component-that-changes-a-value-in-the
let jobFilters = $state([]); let jobFilters = $state([]);
let filterBuffer = $state([]);
let jobList = $state(null); let jobList = $state(null);
let matchedListJobs = $state(0); let matchedListJobs = $state(0);
let isSortingOpen = $state(false); let isSortingOpen = $state(false);
let isMetricsSelectionOpen = $state(false); let isMetricsSelectionOpen = $state(false);
let sorting = $state({ field: "startTime", type: "col", order: "DESC" }); let sorting = $state({ field: "startTime", type: "col", order: "DESC" });
let selectedHistogramsBuffer = $state({ all: (ccconfig['userView_histogramMetrics'] || []) }) let selectedHistogramsBuffer = $state({ all: (ccconfig['userView_histogramMetrics'] || []) })
let jobCompare = $state(null);
let matchedCompareJobs = $state(0);
let showCompare = $state(false);
let selectedJobs = $state([]);
// Histogram Vars // Histogram Vars
let isHistogramSelectionOpen = $state(false); let isHistogramSelectionOpen = $state(false);
let numDurationBins = $state("1h"); let numDurationBins = $state("1h");
let numMetricBins = $state(10); let numMetricBins = $state(10);
// Compare Vars (TODO)
// let jobCompare = $state(null);
// let showCompare = $state(false);
// let selectedJobs = $state([]);
// let filterBuffer = $state([]);
// let matchedCompareJobs = $state(0);
/* Derived */ /* Derived */
let selectedCluster = $derived(filterPresets?.cluster ? filterPresets.cluster : null); let selectedCluster = $derived(filterPresets?.cluster ? filterPresets.cluster : null);
let metrics = $derived(filterPresets.cluster let selectedSubCluster = $derived(filterPresets?.partition ? filterPresets.partition : null);
? ccconfig[`metricConfig_jobListMetrics:${filterPresets.cluster}`] || let metrics = $derived.by(() => {
ccconfig.metricConfig_jobListMetrics if (selectedCluster) {
: ccconfig.metricConfig_jobListMetrics if (selectedSubCluster) {
); return ccconfig[`metricConfig_jobListMetrics:${selectedCluster}:${selectedSubCluster}`] ||
ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] ||
ccconfig.metricConfig_jobListMetrics
}
return ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] ||
ccconfig.metricConfig_jobListMetrics
}
return ccconfig.metricConfig_jobListMetrics
});
let showFootprint = $derived(filterPresets.cluster let showFootprint = $derived(filterPresets.cluster
? !!ccconfig[`jobList_showFootprint:${filterPresets.cluster}`] ? !!ccconfig[`jobList_showFootprint:${filterPresets.cluster}`]
: !!ccconfig.jobList_showFootprint : !!ccconfig.jobList_showFootprint
@@ -126,18 +134,29 @@
}) })
); );
/* Effect */ /* Functions */
function resetJobSelection() {
if (filterComponent && selectedJobs.length === 0) {
filterComponent.updateFilters({ dbId: [] });
};
};
/* Reactive Effects */
$effect(() => {
// Reactive : Trigger Effect
selectedJobs.length
untrack(() => {
// Unreactive : Apply Reset w/o starting infinite loop
resetJobSelection()
});
});
$effect(() => { $effect(() => {
if (!selectedHistogramsBuffer[selectedCluster]) { if (!selectedHistogramsBuffer[selectedCluster]) {
selectedHistogramsBuffer[selectedCluster] = ccconfig[`userView_histogramMetrics:${selectedCluster}`]; selectedHistogramsBuffer[selectedCluster] = ccconfig[`userView_histogramMetrics:${selectedCluster}`];
}; };
}); });
$effect(() => {
// Load Metric-Selection for last selected cluster
metrics = selectedCluster ? ccconfig[`metricConfig_jobListMetrics:${selectedCluster}`] : ccconfig.metricConfig_jobListMetrics
});
/* On Mount */ /* On Mount */
onMount(() => { onMount(() => {
filterComponent.updateFilters(); filterComponent.updateFilters();
@@ -167,7 +186,7 @@
<Row cols={{ xs: 1, md: 2, lg: 6}} class="mb-3"> <Row cols={{ xs: 1, md: 2, lg: 6}} class="mb-3">
<Col class="mb-2 mb-lg-0"> <Col class="mb-2 mb-lg-0">
<ButtonGroup class="w-100"> <ButtonGroup class="w-100">
<Button outline color="primary" onclick={() => (isSortingOpen = true)}> <Button outline color="primary" onclick={() => (isSortingOpen = true)} disabled={showCompare}>
<Icon name="sort-up" /> Sorting <Icon name="sort-up" /> Sorting
</Button> </Button>
<Button <Button
@@ -181,208 +200,264 @@
</Col> </Col>
<Col lg="4" class="mb-1 mb-lg-0"> <Col lg="4" class="mb-1 mb-lg-0">
<Filters <Filters
startTimeQuickSelect
bind:this={filterComponent} bind:this={filterComponent}
{filterPresets} {filterPresets}
matchedJobs={matchedListJobs} showFilter={!showCompare}
matchedJobs={showCompare? matchedCompareJobs: matchedListJobs}
startTimeQuickSelect
applyFilters={(detail) => { applyFilters={(detail) => {
jobFilters = [...detail.filters, { user: { eq: user.username } }]; jobFilters = [...detail.filters, { user: { eq: user.username } }];
selectedCluster = jobFilters[0]?.cluster selectedCluster = jobFilters[0]?.cluster
? jobFilters[0].cluster.eq ? jobFilters[0].cluster.eq
: null; : null;
jobList.queryJobs(jobFilters); selectedSubCluster = jobFilters[1]?.partition
? jobFilters[1].partition.eq
: null;
filterBuffer = [...jobFilters]
if (showCompare) {
jobCompare.queryJobs(jobFilters);
} else {
jobList.queryJobs(jobFilters);
}
}} }}
/> />
</Col> </Col>
<Col class="mb-2 mb-lg-0"> <Col class="mb-2 mb-lg-0">
<InputGroup> {#if !showCompare}
<InputGroupText> <InputGroup>
<Icon name="bar-chart-line-fill" /> <InputGroupText>
</InputGroupText> <Icon name="bar-chart-line-fill" />
<InputGroupText> </InputGroupText>
Duration Bin Size <InputGroupText>
</InputGroupText> Duration Bin Size
<Input type="select" bind:value={numDurationBins} style="max-width: 120px;"> </InputGroupText>
{#each durationBinOptions as dbin} <Input type="select" bind:value={numDurationBins} style="max-width: 120px;">
<option value={dbin}>{dbin}</option> {#each durationBinOptions as dbin}
{/each} <option value={dbin}>{dbin}</option>
</Input> {/each}
</InputGroup> </Input>
</InputGroup>
{/if}
</Col> </Col>
<Col class="mb-2 mb-lg-0"> <Col class="mb-2 mb-lg-0">
<TextFilter {#if !showCompare}
setFilter={(filter) => filterComponent.updateFilters(filter)} <TextFilter
/> {filterBuffer}
setFilter={(filter) => filterComponent.updateFilters(filter)}
/>
{/if}
</Col> </Col>
<Col class="mb-1 mb-lg-0"> <Col class="mb-1 mb-lg-0">
<Refresher onRefresh={() => { {#if !showCompare}
jobList.refreshJobs() <Refresher onRefresh={() => {
jobList.refreshAllMetrics() jobList.refreshJobs()
}} /> jobList.refreshAllMetrics()
}} />
{/if}
</Col> </Col>
</Row> </Row>
<!-- ROW3: Base Information--> <!-- ROW3: Base Information-->
<Row cols={{ xs: 1, md: 3}} class="mb-2"> {#if !showCompare}
{#if $stats.error} <Row cols={{ xs: 1, md: 3}} class="mb-2">
<Col> {#if $stats.error}
<Card body color="danger">{$stats.error.message}</Card>
</Col>
{:else if !$stats.data}
<Col>
<Spinner secondary />
</Col>
{:else}
<Col>
<Table>
<tbody>
<tr>
<th scope="row">Username</th>
<td>{scrambleNames ? scramble(user.username) : user.username}</td>
</tr>
{#if user.name}
<tr>
<th scope="row">Name</th>
<td>{scrambleNames ? scramble(user.name) : user.name}</td>
</tr>
{/if}
{#if user.email}
<tr>
<th scope="row">Email</th>
<td>{user.email}</td>
</tr>
{/if}
<tr>
<th scope="row">Total Jobs</th>
<td>{$stats.data.jobsStatistics[0].totalJobs}</td>
</tr>
<tr>
<th scope="row">Short Jobs</th>
<td>{$stats.data.jobsStatistics[0].shortJobs}</td>
</tr>
<tr>
<th scope="row">Total Walltime</th>
<td>{$stats.data.jobsStatistics[0].totalWalltime}</td>
</tr>
<tr>
<th scope="row">Total Core Hours</th>
<td>{$stats.data.jobsStatistics[0].totalCoreHours}</td>
</tr>
</tbody>
</Table>
</Col>
<Col class="px-1">
{#key $stats.data.jobsStatistics[0].histDuration}
<Histogram
data={convert2uplot($stats.data.jobsStatistics[0].histDuration)}
title="Duration Distribution"
xlabel="Job Runtimes"
xunit="Runtime"
ylabel="Number of Jobs"
yunit="Jobs"
usesBins
xtime
/>
{/key}
</Col>
<Col class="px-1">
{#key $stats.data.jobsStatistics[0].histNumNodes}
<Histogram
data={convert2uplot($stats.data.jobsStatistics[0].histNumNodes)}
title="Number of Nodes Distribution"
xlabel="Allocated Nodes"
xunit="Nodes"
ylabel="Number of Jobs"
yunit="Jobs"
/>
{/key}
</Col>
{/if}
</Row>
<!-- ROW4+5: Selectable Histograms -->
<Row>
<Col xs="12" md="3" lg="2" class="mb-2 mb-md-0">
<Button
outline
color="secondary"
class="w-100"
onclick={() => (isHistogramSelectionOpen = true)}
>
<Icon name="bar-chart-line" /> Select Histograms
</Button>
</Col>
<Col xs="12" md="9" lg="10" class="mb-2 mb-md-0">
<InputGroup>
<InputGroupText>
<Icon name="bar-chart-line-fill" />
</InputGroupText>
<InputGroupText>
Metric Bins
</InputGroupText>
<Input type="select" bind:value={numMetricBins} style="max-width: 120px;">
{#each metricBinOptions as mbin}
<option value={mbin}>{mbin}</option>
{/each}
</Input>
</InputGroup>
</Col>
</Row>
{#if selectedHistograms?.length > 0}
{#if $stats.error}
<Row>
<Col> <Col>
<Card body color="danger">{$stats.error.message}</Card> <Card body color="danger">{$stats.error.message}</Card>
</Col> </Col>
</Row> {:else if !$stats.data}
{:else if !$stats.data}
<Row>
<Col> <Col>
<Spinner secondary /> <Spinner secondary />
</Col> </Col>
</Row> {:else}
{:else} <Col>
<hr class="my-2"/> <Table>
<!-- Note: Ignore '#snippet' Error in IDE --> <tbody>
{#snippet gridContent(item)} <tr>
<Histogram <th scope="row">Username</th>
data={convert2uplot(item.data)} <td>{scrambleNames ? scramble(user.username) : user.username}</td>
title="Distribution of '{item.metric} ({item.stat})' footprints" </tr>
xlabel={`${item.metric} bin maximum ${item?.unit ? `[${item.unit}]` : ``}`} {#if user.name}
xunit={item.unit} <tr>
ylabel="Number of Jobs" <th scope="row">Name</th>
yunit="Jobs" <td>{scrambleNames ? scramble(user.name) : user.name}</td>
usesBins </tr>
enableFlip {/if}
/> {#if user.email}
{/snippet} <tr>
<th scope="row">Email</th>
{#key $stats.data.jobsStatistics[0].histMetrics} <td>{user.email}</td>
<PlotGrid </tr>
items={$stats.data.jobsStatistics[0].histMetrics} {/if}
itemsPerRow={3} <tr>
{gridContent} <th scope="row">Total Jobs</th>
/> <td>{$stats.data.jobsStatistics[0].totalJobs}</td>
{/key} </tr>
{/if} <tr>
{:else} <th scope="row">Short Jobs</th>
<Row class="mt-2"> <td>{$stats.data.jobsStatistics[0].shortJobs}</td>
<Col> </tr>
<Card body>No footprint histograms selected.</Card> <tr>
</Col> <th scope="row">Total Walltime</th>
<td>{$stats.data.jobsStatistics[0].totalWalltime}</td>
</tr>
<tr>
<th scope="row">Total Core Hours</th>
<td>{$stats.data.jobsStatistics[0].totalCoreHours}</td>
</tr>
</tbody>
</Table>
</Col>
<Col class="px-1">
{#key $stats.data.jobsStatistics[0].histDuration}
<Histogram
data={convert2uplot($stats.data.jobsStatistics[0].histDuration)}
title="Duration Distribution"
xlabel="Job Runtimes"
xunit="Runtime"
ylabel="Number of Jobs"
yunit="Jobs"
usesBins
xtime
/>
{/key}
</Col>
<Col class="px-1">
{#key $stats.data.jobsStatistics[0].histNumNodes}
<Histogram
data={convert2uplot($stats.data.jobsStatistics[0].histNumNodes)}
title="Number of Nodes Distribution"
xlabel="Allocated Nodes"
xunit="Nodes"
ylabel="Number of Jobs"
yunit="Jobs"
/>
{/key}
</Col>
{/if}
</Row> </Row>
{/if} {/if}
<!-- ROW6: JOB LIST--> <!-- ROW4+5: Selectable Histograms -->
{#if !showCompare}
<Row>
<Col xs="12" md="3" lg="2" class="mb-2 mb-md-0">
<Button
outline
color="secondary"
class="w-100"
onclick={() => (isHistogramSelectionOpen = true)}
>
<Icon name="bar-chart-line" /> Select Histograms
</Button>
</Col>
<Col xs="12" md="9" lg="10" class="mb-2 mb-md-0">
<InputGroup>
<InputGroupText>
<Icon name="bar-chart-line-fill" />
</InputGroupText>
<InputGroupText>
Metric Bins
</InputGroupText>
<Input type="select" bind:value={numMetricBins} style="max-width: 120px;">
{#each metricBinOptions as mbin}
<option value={mbin}>{mbin}</option>
{/each}
</Input>
</InputGroup>
</Col>
</Row>
{#if selectedHistograms?.length > 0}
{#if $stats.error}
<Row>
<Col>
<Card body color="danger">{$stats.error.message}</Card>
</Col>
</Row>
{:else if !$stats.data}
<Row>
<Col>
<Spinner secondary />
</Col>
</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
enableFlip
/>
{/snippet}
{#key $stats.data.jobsStatistics[0].histMetrics}
<PlotGrid
items={$stats.data.jobsStatistics[0].histMetrics}
itemsPerRow={3}
{gridContent}
/>
{/key}
{/if}
{:else}
<Row class="mt-2">
<Col>
<Card body>No footprint histograms selected.</Card>
</Col>
</Row>
{/if}
{/if}
<!-- ROW6: JOB COMPARE TRIGGER-->
<Row class="mt-3"> <Row class="mt-3">
<Col xs="12" md="3" class="mb-2 mb-md-0">
<ButtonGroup>
<Button color="primary" disabled={(matchedListJobs >= matchedJobCompareLimit && !(selectedJobs.length != 0)) || $initq.fetching} onclick={() => {
if (selectedJobs.length != 0) filterComponent.updateFilters({dbId: selectedJobs})
showCompare = !showCompare
}} >
{showCompare ? 'Return to List' :
matchedListJobs >= matchedJobCompareLimit && selectedJobs.length == 0
? 'Compare Disabled'
: 'Compare' + (selectedJobs.length != 0 ? ` ${selectedJobs.length} ` : ' ') + 'Jobs'
}
</Button>
{#if !showCompare && selectedJobs.length != 0}
<Button class="w-auto" color="warning" onclick={() => {
selectedJobs = [] // Only empty array, filters handled by reactive reset
}}>
Clear
</Button>
{/if}
</ButtonGroup>
</Col>
</Row>
<!-- ROW7: JOB LIST / COMPARE-->
<Row class="mt-2">
<Col> <Col>
<JobList {#if !showCompare}
bind:this={jobList} <JobList
bind:matchedListJobs bind:this={jobList}
{metrics} bind:matchedListJobs
{sorting} bind:selectedJobs
{showFootprint} {metrics}
/> {sorting}
{showFootprint}
{filterBuffer}
/>
{:else}
<JobCompare
bind:this={jobCompare}
bind:matchedCompareJobs
{metrics}
{filterBuffer}
/>
{/if}
</Col> </Col>
</Row> </Row>
@@ -399,6 +474,7 @@
bind:showFootprint bind:showFootprint
presetMetrics={metrics} presetMetrics={metrics}
cluster={selectedCluster} cluster={selectedCluster}
subCluster={selectedSubCluster}
configName="metricConfig_jobListMetrics" configName="metricConfig_jobListMetrics"
footprintSelect footprintSelect
applyMetrics={(newMetrics) => applyMetrics={(newMetrics) =>