add manual job selection for comparison in jobs view

This commit is contained in:
Christoph Kluge 2025-05-08 09:28:48 +02:00
parent 4419df8d1b
commit 69286881e4
12 changed files with 106 additions and 45 deletions

View File

@ -300,8 +300,8 @@ type TimeRangeOutput { range: String, from: Time!, to: Time! }
input JobFilter { input JobFilter {
tags: [ID!] tags: [ID!]
dbId: [ID!]
jobId: StringInput jobId: StringInput
jobIds: [ID!]
arrayJobId: Int arrayJobId: Int
user: StringInput user: StringInput
project: StringInput project: StringInput

View File

@ -2465,8 +2465,8 @@ type TimeRangeOutput { range: String, from: Time!, to: Time! }
input JobFilter { input JobFilter {
tags: [ID!] tags: [ID!]
dbId: [ID!]
jobId: StringInput jobId: StringInput
jobIds: [ID!]
arrayJobId: Int arrayJobId: Int
user: StringInput user: StringInput
project: StringInput project: StringInput
@ -16447,7 +16447,7 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj any
asMap[k] = v asMap[k] = v
} }
fieldsInOrder := [...]string{"tags", "jobId", "jobIds", "arrayJobId", "user", "project", "jobName", "cluster", "partition", "duration", "energy", "minRunningFor", "numNodes", "numAccelerators", "numHWThreads", "startTime", "state", "metricStats", "exclusive", "node"} fieldsInOrder := [...]string{"tags", "dbId", "jobId", "arrayJobId", "user", "project", "jobName", "cluster", "partition", "duration", "energy", "minRunningFor", "numNodes", "numAccelerators", "numHWThreads", "startTime", "state", "metricStats", "exclusive", "node"}
for _, k := range fieldsInOrder { for _, k := range fieldsInOrder {
v, ok := asMap[k] v, ok := asMap[k]
if !ok { if !ok {
@ -16461,6 +16461,13 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj any
return it, err return it, err
} }
it.Tags = data it.Tags = data
case "dbId":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("dbId"))
data, err := ec.unmarshalOID2ᚕstringᚄ(ctx, v)
if err != nil {
return it, err
}
it.DbID = data
case "jobId": case "jobId":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("jobId")) ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("jobId"))
data, err := ec.unmarshalOStringInput2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐStringInput(ctx, v) data, err := ec.unmarshalOStringInput2ᚖgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋinternalᚋgraphᚋmodelᚐStringInput(ctx, v)
@ -16468,13 +16475,6 @@ func (ec *executionContext) unmarshalInputJobFilter(ctx context.Context, obj any
return it, err return it, err
} }
it.JobID = data it.JobID = data
case "jobIds":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("jobIds"))
data, err := ec.unmarshalOID2ᚕstringᚄ(ctx, v)
if err != nil {
return it, err
}
it.JobIds = data
case "arrayJobId": case "arrayJobId":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("arrayJobId")) ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("arrayJobId"))
data, err := ec.unmarshalOInt2ᚖint(ctx, v) data, err := ec.unmarshalOInt2ᚖint(ctx, v)

View File

@ -50,8 +50,8 @@ type IntRangeOutput struct {
type JobFilter struct { type JobFilter struct {
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
DbID []string `json:"dbId,omitempty"`
JobID *StringInput `json:"jobId,omitempty"` JobID *StringInput `json:"jobId,omitempty"`
JobIds []string `json:"jobIds,omitempty"`
ArrayJobID *int `json:"arrayJobId,omitempty"` ArrayJobID *int `json:"arrayJobId,omitempty"`
User *StringInput `json:"user,omitempty"` User *StringInput `json:"user,omitempty"`
Project *StringInput `json:"project,omitempty"` Project *StringInput `json:"project,omitempty"`

View File

@ -146,17 +146,16 @@ func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.Select
// This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query? // This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct() query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
} }
if filter.DbID != nil {
dbIDs := make([]string, len(filter.DbID))
for i, val := range filter.DbID {
dbIDs[i] = val
}
query = query.Where(sq.Eq{"job.id": dbIDs})
}
if filter.JobID != nil { if filter.JobID != nil {
query = buildStringCondition("job.job_id", filter.JobID, query) query = buildStringCondition("job.job_id", filter.JobID, query)
} }
if filter.JobIds != nil {
jobIds := make([]string, len(filter.JobIds))
for i, val := range filter.JobIds {
jobIds[i] = string(val)
}
query = query.Where(sq.Eq{"job.job_id": jobIds})
}
if filter.ArrayJobID != nil { if filter.ArrayJobID != nil {
query = query.Where("job.array_job_id = ?", *filter.ArrayJobID) query = query.Where("job.array_job_id = ?", *filter.ArrayJobID)
} }

View File

@ -297,6 +297,9 @@ func buildFilterPresets(query url.Values) map[string]interface{} {
} }
} }
} }
if len(query["dbId"]) != 0 {
filterPresets["dbId"] = query["dbId"]
}
if query.Get("jobId") != "" { if query.Get("jobId") != "" {
if len(query["jobId"]) == 1 { if len(query["jobId"]) == 1 {
filterPresets["jobId"] = query.Get("jobId") filterPresets["jobId"] = query.Get("jobId")

View File

@ -37,6 +37,7 @@
let filterComponent; // see why here: https://stackoverflow.com/questions/58287729/how-can-i-export-a-function-from-a-svelte-component-that-changes-a-value-in-the let filterComponent; // see why here: https://stackoverflow.com/questions/58287729/how-can-i-export-a-function-from-a-svelte-component-that-changes-a-value-in-the
let filterBuffer = []; let filterBuffer = [];
let selectedJobs = [];
let jobList, let jobList,
jobCompare, jobCompare,
matchedListJobs, matchedListJobs,
@ -59,6 +60,10 @@
// so we need to wait for it to be ready before we can start a query. // so we need to wait for it to be ready before we can start a query.
// This is also why JobList component starts out with a paused query. // This is also why JobList component starts out with a paused query.
onMount(() => filterComponent.updateFilters()); onMount(() => filterComponent.updateFilters());
$: if (filterComponent && selectedJobs.length == 0) {
filterComponent.updateFilters({dbId: []})
}
</script> </script>
<!-- ROW1: Status--> <!-- ROW1: Status-->
@ -80,7 +85,7 @@
<Row cols={{ xs: 1, md: 2, lg: 5}} class="mb-3"> <Row cols={{ xs: 1, md: 2, lg: 5}} class="mb-3">
<Col lg="2" class="mb-2 mb-lg-0"> <Col lg="2" class="mb-2 mb-lg-0">
<ButtonGroup class="w-100"> <ButtonGroup class="w-100">
<Button outline color="primary" on:click={() => (isSortingOpen = true)}> <Button outline color="primary" on:click={() => (isSortingOpen = true)} disabled={showCompare}>
<Icon name="sort-up" /> Sorting <Icon name="sort-up" /> Sorting
</Button> </Button>
<Button <Button
@ -130,11 +135,20 @@
}} /> }} />
</Col> </Col>
<Col lg="2" class="mb-2 mb-lg-0"> <Col lg="2" class="mb-2 mb-lg-0">
<ButtonGroup class="w-100">
<Button color="primary" on:click={() => { <Button color="primary" on:click={() => {
if (selectedJobs.length != 0) filterComponent.updateFilters({dbId: selectedJobs})
else if (selectedJobs.length == 0) filterComponent.updateFilters({dbId: []})
showCompare = !showCompare showCompare = !showCompare
}} > }} >
{showCompare ? 'List' : 'Compare'} Jobs {showCompare ? 'List' : 'Compare'} Jobs {selectedJobs.length != 0 ? `(${selectedJobs.length} selected)` : `(Use Filter)`}
</Button> </Button>
<Button color="danger" disabled={selectedJobs.length == 0} on:click={() => {
selectedJobs = [] // Only empty array, filters handled by reactive reset
}}>
Reset
</Button>
</ButtonGroup>
</Col> </Col>
</Row> </Row>
@ -148,6 +162,7 @@
bind:sorting bind:sorting
bind:matchedListJobs bind:matchedListJobs
bind:showFootprint bind:showFootprint
bind:selectedJobs
{filterBuffer} {filterBuffer}
/> />
{:else} {:else}
@ -161,7 +176,7 @@
</Col> </Col>
</Row> </Row>
<Sorting bind:sorting bind:isOpen={isSortingOpen} /> <Sorting bind:sorting bind:isOpen={isSortingOpen}/>
<MetricSelection <MetricSelection
bind:cluster={selectedCluster} bind:cluster={selectedCluster}

View File

@ -78,6 +78,7 @@
from: null, from: null,
to: null, to: null,
}, },
dbId: filterPresets.dbId || [],
jobId: filterPresets.jobId || "", jobId: filterPresets.jobId || "",
arrayJobId: filterPresets.arrayJobId || null, arrayJobId: filterPresets.arrayJobId || null,
user: filterPresets.user || "", user: filterPresets.user || "",
@ -137,6 +138,8 @@
items.push({ items.push({
energy: { from: filters.energy.from, to: filters.energy.to }, energy: { from: filters.energy.from, to: filters.energy.to },
}); });
if (filters.dbId.length != 0)
items.push({ dbId: filters.dbId });
if (filters.jobId) if (filters.jobId)
items.push({ jobId: { [filters.jobIdMatch]: filters.jobId } }); items.push({ jobId: { [filters.jobIdMatch]: filters.jobId } });
if (filters.arrayJobId != null) if (filters.arrayJobId != null)
@ -180,7 +183,6 @@
function changeURL() { function changeURL() {
const dateToUnixEpoch = (rfc3339) => Math.floor(Date.parse(rfc3339) / 1000); const dateToUnixEpoch = (rfc3339) => Math.floor(Date.parse(rfc3339) / 1000);
let opts = []; let opts = [];
if (filters.cluster) opts.push(`cluster=${filters.cluster}`); if (filters.cluster) opts.push(`cluster=${filters.cluster}`);
if (filters.node) opts.push(`node=${filters.node}`); if (filters.node) opts.push(`node=${filters.node}`);
@ -196,6 +198,11 @@
if (filters.startTime.range) { if (filters.startTime.range) {
opts.push(`startTime=${filters.startTime.range}`) opts.push(`startTime=${filters.startTime.range}`)
} }
if (filters.dbId.length != 0) {
for (let dbi of filters.dbId) {
opts.push(`dbId=${dbi}`);
}
}
if (filters.jobId.length != 0) if (filters.jobId.length != 0)
if (filters.jobIdMatch != "in") { if (filters.jobIdMatch != "in") {
opts.push(`jobId=${filters.jobId}`); opts.push(`jobId=${filters.jobId}`);

View File

@ -39,6 +39,7 @@
export let metrics = ccconfig.plot_list_selectedMetrics; export let metrics = ccconfig.plot_list_selectedMetrics;
export let showFootprint; export let showFootprint;
export let filterBuffer = []; export let filterBuffer = [];
export let selectedJobs = [];
let usePaging = ccconfig.job_list_usePaging let usePaging = ccconfig.job_list_usePaging
let itemsPerPage = usePaging ? ccconfig.plot_list_jobsPerPage : 10; let itemsPerPage = usePaging ? ccconfig.plot_list_jobsPerPage : 10;
@ -285,7 +286,10 @@
</tr> </tr>
{:else} {:else}
{#each jobs as job (job)} {#each jobs as job (job)}
<JobListRow bind:triggerMetricRefresh {job} {metrics} {plotWidth} {showFootprint} /> <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)}
/>
{:else} {:else}
<tr> <tr>
<td colspan={metrics.length + 1}> No jobs found </td> <td colspan={metrics.length + 1}> No jobs found </td>

View File

@ -18,6 +18,8 @@
export let username = null; export let username = null;
export let authlevel= null; export let authlevel= null;
export let roles = null; export let roles = null;
export let isSelected = null;
export let showSelect = false;
function formatDuration(duration) { function formatDuration(duration) {
const hours = Math.floor(duration / 3600); const hours = Math.floor(duration / 3600);
@ -76,6 +78,26 @@
<a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a> <a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a>
({job.cluster}) ({job.cluster})
</span> </span>
<span>
{#if showSelect}
<Button id={`${job.cluster}-${job.jobId}-select`} outline={!isSelected} color={isSelected? `success`: `secondary`} size="sm" class="mr-2"
on:click={() => {
isSelected = !isSelected
}}>
{#if isSelected}
<Icon name="check-square"/>
{:else if isSelected == false}
<Icon name="square"/>
{:else}
<Icon name="plus-square-dotted" />
{/if}
</Button>
<Tooltip
target={`${job.cluster}-${job.jobId}-select`}
placement="left">
{ 'Add or Remove Job to/from Comparison Selection' }
</Tooltip>
{/if}
<Button id={`${job.cluster}-${job.jobId}-clipboard`} outline color="secondary" size="sm" on:click={clipJobId(job.jobId)} > <Button id={`${job.cluster}-${job.jobId}-clipboard`} outline color="secondary" size="sm" on:click={clipJobId(job.jobId)} >
{#if displayCheck} {#if displayCheck}
<Icon name="clipboard2-check-fill"/> <Icon name="clipboard2-check-fill"/>
@ -89,6 +111,7 @@
{ displayCheck ? 'Copied!' : 'Copy Job ID to Clipboard' } { displayCheck ? 'Copied!' : 'Copy Job ID to Clipboard' }
</Tooltip> </Tooltip>
</span> </span>
</span>
{#if job.metaData?.jobName} {#if job.metaData?.jobName}
{#if job.metaData?.jobName.length <= 25} {#if job.metaData?.jobName.length <= 25}
<div>{job.metaData.jobName}</div> <div>{job.metaData.jobName}</div>

View File

@ -12,7 +12,7 @@
<script> <script>
import { queryStore, gql, getContextClient } from "@urql/svelte"; import { queryStore, gql, getContextClient } from "@urql/svelte";
import { getContext } from "svelte"; import { getContext, createEventDispatcher } from "svelte";
import { Card, Spinner } from "@sveltestrap/sveltestrap"; import { Card, Spinner } from "@sveltestrap/sveltestrap";
import { maxScope, checkMetricDisabled } from "../utils.js"; import { maxScope, checkMetricDisabled } from "../utils.js";
import JobInfo from "./JobInfo.svelte"; import JobInfo from "./JobInfo.svelte";
@ -25,7 +25,9 @@
export let plotHeight = 275; export let plotHeight = 275;
export let showFootprint; export let showFootprint;
export let triggerMetricRefresh = false; export let triggerMetricRefresh = false;
export let previousSelect = false;
const dispatch = createEventDispatcher();
const resampleConfig = getContext("resampling") || null; const resampleConfig = getContext("resampling") || null;
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0; const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
@ -39,6 +41,8 @@
let zoomStates = {}; let zoomStates = {};
let thresholdStates = {}; let thresholdStates = {};
$: isSelected = previousSelect || null;
const cluster = getContext("clusters").find((c) => c.name == job.cluster); const cluster = getContext("clusters").find((c) => c.name == job.cluster);
const client = getContextClient(); const client = getContextClient();
const query = gql` const query = gql`
@ -112,6 +116,12 @@
refreshMetrics(); refreshMetrics();
} }
$: if (isSelected == true && previousSelect == false) {
dispatch("select-job", job.id)
} else if (isSelected == false && previousSelect == true) {
dispatch("unselect-job", job.id)
}
// Helper // Helper
const selectScope = (jobMetrics) => const selectScope = (jobMetrics) =>
jobMetrics.reduce( jobMetrics.reduce(
@ -152,7 +162,7 @@
<tr> <tr>
<td> <td>
<JobInfo {job} /> <JobInfo {job} bind:isSelected showSelect/>
</td> </td>
{#if job.monitoringStatus == 0 || job.monitoringStatus == 2} {#if job.monitoringStatus == 0 || job.monitoringStatus == 2}
<td colspan={metrics.length}> <td colspan={metrics.length}>

View File

@ -308,7 +308,7 @@
style="background-color: rgba(255, 255, 255, 1.0);" class="rounded" style="background-color: rgba(255, 255, 255, 1.0);" class="rounded"
/> />
{:else} {:else}
<Card body color="warning" class="mx-4" <Card body color="warning" class="mx-4 my-2"
>Cannot render plot: No series data returned for <code>{metric}</code></Card >Cannot render plot: No series data returned for <code>{metric?metric:'job resources'}</code></Card
> >
{/if} {/if}

View File

@ -461,11 +461,11 @@ export function convert2uplot(canvasData, secondsToMinutes = false, secondsToHou
} else { // Default -> Fill Histodata with zero values on unused value placing -> maybe allows zoom trigger as known } else { // Default -> Fill Histodata with zero values on unused value placing -> maybe allows zoom trigger as known
if (secondsToHours) { if (secondsToHours) {
let hours = cd.value / 3600 let hours = cd.value / 3600
console.log("x seconds to y hours", cd.value, hours) // console.log("x seconds to y hours", cd.value, hours)
uplotData[0].push(hours) uplotData[0].push(hours)
} else if (secondsToMinutes) { } else if (secondsToMinutes) {
let minutes = cd.value / 60 let minutes = cd.value / 60
console.log("x seconds to y minutes", cd.value, minutes) // console.log("x seconds to y minutes", cd.value, minutes)
uplotData[0].push(minutes) uplotData[0].push(minutes)
} else { } else {
uplotData[0].push(cd.value) uplotData[0].push(cd.value)