Merge pull request #391 from ClusterCockpit/add_job_comparison

Add job comparison
This commit is contained in:
Jan Eitzinger 2025-05-13 14:18:22 +02:00 committed by GitHub
commit b323ce2eef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2413 additions and 645 deletions

View File

@ -158,7 +158,7 @@ type StatsSeries {
max: [NullableFloat!]! max: [NullableFloat!]!
} }
type JobStatsWithScope { type NamedStatsWithScope {
name: String! name: String!
scope: MetricScope! scope: MetricScope!
stats: [ScopedStats!]! stats: [ScopedStats!]!
@ -171,8 +171,21 @@ type ScopedStats {
} }
type JobStats { type JobStats {
name: String! id: Int!
stats: MetricStatistics! jobId: String!
startTime: Int!
duration: Int!
cluster: String!
subCluster: String!
numNodes: Int!
numHWThreads: Int
numAccelerators: Int
stats: [NamedStats!]!
}
type NamedStats {
name: String!
data: MetricStatistics!
} }
type Unit { type Unit {
@ -259,12 +272,13 @@ type Query {
job(id: ID!): Job job(id: ID!): Job
jobMetrics(id: ID!, metrics: [String!], scopes: [MetricScope!], resolution: Int): [JobMetricWithName!]! jobMetrics(id: ID!, metrics: [String!], scopes: [MetricScope!], resolution: Int): [JobMetricWithName!]!
jobStats(id: ID!, metrics: [String!]): [JobStats!]! jobStats(id: ID!, metrics: [String!]): [NamedStats!]!
scopedJobStats(id: ID!, metrics: [String!], scopes: [MetricScope!]): [JobStatsWithScope!]! scopedJobStats(id: ID!, metrics: [String!], scopes: [MetricScope!]): [NamedStatsWithScope!]!
jobsFootprints(filter: [JobFilter!], metrics: [String!]!): Footprints
jobs(filter: [JobFilter!], page: PageRequest, order: OrderByInput): JobResultList! jobs(filter: [JobFilter!], page: PageRequest, order: OrderByInput): JobResultList!
jobsStatistics(filter: [JobFilter!], metrics: [String!], page: PageRequest, sortBy: SortByAggregate, groupBy: Aggregate, numDurationBins: String, numMetricBins: Int): [JobsStatistics!]! jobsStatistics(filter: [JobFilter!], metrics: [String!], page: PageRequest, sortBy: SortByAggregate, groupBy: Aggregate, numDurationBins: String, numMetricBins: Int): [JobsStatistics!]!
jobsMetricStats(filter: [JobFilter!], metrics: [String!]): [JobStats!]!
jobsFootprints(filter: [JobFilter!], metrics: [String!]!): Footprints
rooflineHeatmap(filter: [JobFilter!]!, rows: Int!, cols: Int!, minX: Float!, minY: Float!, maxX: Float!, maxY: Float!): [[Float!]!]! rooflineHeatmap(filter: [JobFilter!]!, rows: Int!, cols: Int!, minX: Float!, minY: Float!, maxX: Float!, maxY: Float!): [[Float!]!]!
@ -287,6 +301,7 @@ type TimeRangeOutput { range: String, from: Time!, to: Time! }
input JobFilter { input JobFilter {
tags: [ID!] tags: [ID!]
dbId: [ID!]
jobId: StringInput jobId: StringInput
arrayJobId: Int arrayJobId: Int
user: StringInput user: StringInput

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,7 @@ 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"`
ArrayJobID *int `json:"arrayJobId,omitempty"` ArrayJobID *int `json:"arrayJobId,omitempty"`
User *StringInput `json:"user,omitempty"` User *StringInput `json:"user,omitempty"`
@ -96,14 +97,16 @@ type JobResultList struct {
} }
type JobStats struct { type JobStats struct {
Name string `json:"name"` ID int `json:"id"`
Stats *schema.MetricStatistics `json:"stats"` JobID string `json:"jobId"`
} StartTime int `json:"startTime"`
Duration int `json:"duration"`
type JobStatsWithScope struct { Cluster string `json:"cluster"`
Name string `json:"name"` SubCluster string `json:"subCluster"`
Scope schema.MetricScope `json:"scope"` NumNodes int `json:"numNodes"`
Stats []*ScopedStats `json:"stats"` NumHWThreads *int `json:"numHWThreads,omitempty"`
NumAccelerators *int `json:"numAccelerators,omitempty"`
Stats []*NamedStats `json:"stats"`
} }
type JobsStatistics struct { type JobsStatistics struct {
@ -153,6 +156,17 @@ type MetricStatItem struct {
type Mutation struct { type Mutation struct {
} }
type NamedStats struct {
Name string `json:"name"`
Data *schema.MetricStatistics `json:"data"`
}
type NamedStatsWithScope struct {
Name string `json:"name"`
Scope schema.MetricScope `json:"scope"`
Stats []*ScopedStats `json:"stats"`
}
type NodeMetrics struct { type NodeMetrics struct {
Host string `json:"host"` Host string `json:"host"`
SubCluster string `json:"subCluster"` SubCluster string `json:"subCluster"`

View File

@ -400,7 +400,7 @@ func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []str
} }
// JobStats is the resolver for the jobStats field. // JobStats is the resolver for the jobStats field.
func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []string) ([]*model.JobStats, error) { func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []string) ([]*model.NamedStats, error) {
job, err := r.Query().Job(ctx, id) job, err := r.Query().Job(ctx, id)
if err != nil { if err != nil {
log.Warnf("Error while querying job %s for metadata", id) log.Warnf("Error while querying job %s for metadata", id)
@ -413,11 +413,11 @@ func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []strin
return nil, err return nil, err
} }
res := []*model.JobStats{} res := []*model.NamedStats{}
for name, md := range data { for name, md := range data {
res = append(res, &model.JobStats{ res = append(res, &model.NamedStats{
Name: name, Name: name,
Stats: &md, Data: &md,
}) })
} }
@ -425,7 +425,7 @@ func (r *queryResolver) JobStats(ctx context.Context, id string, metrics []strin
} }
// ScopedJobStats is the resolver for the scopedJobStats field. // ScopedJobStats is the resolver for the scopedJobStats field.
func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.JobStatsWithScope, error) { func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics []string, scopes []schema.MetricScope) ([]*model.NamedStatsWithScope, error) {
job, err := r.Query().Job(ctx, id) job, err := r.Query().Job(ctx, id)
if err != nil { if err != nil {
log.Warnf("Error while querying job %s for metadata", id) log.Warnf("Error while querying job %s for metadata", id)
@ -438,7 +438,7 @@ func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics [
return nil, err return nil, err
} }
res := make([]*model.JobStatsWithScope, 0) res := make([]*model.NamedStatsWithScope, 0)
for name, scoped := range data { for name, scoped := range data {
for scope, stats := range scoped { for scope, stats := range scoped {
@ -451,7 +451,7 @@ func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics [
}) })
} }
res = append(res, &model.JobStatsWithScope{ res = append(res, &model.NamedStatsWithScope{
Name: name, Name: name,
Scope: scope, Scope: scope,
Stats: mdlStats, Stats: mdlStats,
@ -462,12 +462,6 @@ func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics [
return res, nil return res, nil
} }
// JobsFootprints is the resolver for the jobsFootprints field.
func (r *queryResolver) JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error) {
// NOTE: Legacy Naming! This resolver is for normalized histograms in analysis view only - *Not* related to DB "footprint" column!
return r.jobsFootprints(ctx, filter, metrics)
}
// Jobs is the resolver for the jobs field. // Jobs is the resolver for the jobs field.
func (r *queryResolver) Jobs(ctx context.Context, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) (*model.JobResultList, error) { func (r *queryResolver) Jobs(ctx context.Context, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) (*model.JobResultList, error) {
if page == nil { if page == nil {
@ -589,6 +583,62 @@ func (r *queryResolver) JobsStatistics(ctx context.Context, filter []*model.JobF
return stats, nil return stats, nil
} }
// JobsMetricStats is the resolver for the jobsMetricStats field.
func (r *queryResolver) JobsMetricStats(ctx context.Context, filter []*model.JobFilter, metrics []string) ([]*model.JobStats, error) {
// No Paging, Fixed Order by StartTime ASC
order := &model.OrderByInput{
Field: "startTime",
Type: "col",
Order: "ASC",
}
jobs, err := r.Repo.QueryJobs(ctx, filter, nil, order)
if err != nil {
log.Warn("Error while querying jobs for comparison")
return nil, err
}
res := []*model.JobStats{}
for _, job := range jobs {
data, err := metricDataDispatcher.LoadJobStats(job, metrics, ctx)
if err != nil {
log.Warnf("Error while loading comparison jobStats data for job id %d", job.JobID)
continue
// return nil, err
}
sres := []*model.NamedStats{}
for name, md := range data {
sres = append(sres, &model.NamedStats{
Name: name,
Data: &md,
})
}
numThreadsInt := int(job.NumHWThreads)
numAccsInt := int(job.NumAcc)
res = append(res, &model.JobStats{
ID: int(job.ID),
JobID: strconv.Itoa(int(job.JobID)),
StartTime: int(job.StartTime.Unix()),
Duration: int(job.Duration),
Cluster: job.Cluster,
SubCluster: job.SubCluster,
NumNodes: int(job.NumNodes),
NumHWThreads: &numThreadsInt,
NumAccelerators: &numAccsInt,
Stats: sres,
})
}
return res, err
}
// JobsFootprints is the resolver for the jobsFootprints field.
func (r *queryResolver) JobsFootprints(ctx context.Context, filter []*model.JobFilter, metrics []string) (*model.Footprints, error) {
// NOTE: Legacy Naming! This resolver is for normalized histograms in analysis view only - *Not* related to DB "footprint" column!
return r.jobsFootprints(ctx, filter, metrics)
}
// RooflineHeatmap is the resolver for the rooflineHeatmap field. // RooflineHeatmap is the resolver for the rooflineHeatmap field.
func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error) { func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.JobFilter, rows int, cols int, minX float64, minY float64, maxX float64, maxY float64) ([][]float64, error) {
return r.rooflineHeatmap(ctx, filter, rows, cols, minX, minY, maxX, maxY) return r.rooflineHeatmap(ctx, filter, rows, cols, minX, minY, maxX, maxY)

View File

@ -146,6 +146,13 @@ 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)
} }

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

@ -21,6 +21,7 @@
import { init } from "./generic/utils.js"; import { init } from "./generic/utils.js";
import Filters from "./generic/Filters.svelte"; import Filters from "./generic/Filters.svelte";
import JobList from "./generic/JobList.svelte"; import JobList from "./generic/JobList.svelte";
import JobCompare from "./generic/JobCompare.svelte";
import TextFilter from "./generic/helper/TextFilter.svelte"; import TextFilter from "./generic/helper/TextFilter.svelte";
import Refresher from "./generic/helper/Refresher.svelte"; import Refresher from "./generic/helper/Refresher.svelte";
import Sorting from "./generic/select/SortSelection.svelte"; import Sorting from "./generic/select/SortSelection.svelte";
@ -35,8 +36,12 @@
export let roles; export let roles;
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 selectedJobs = [];
let jobList, let jobList,
matchedJobs = null; jobCompare,
matchedListJobs,
matchedCompareJobs = null;
let sorting = { field: "startTime", type: "col", order: "DESC" }, let sorting = { field: "startTime", type: "col", order: "DESC" },
isSortingOpen = false, isSortingOpen = false,
isMetricsSelectionOpen = false; isMetricsSelectionOpen = false;
@ -49,11 +54,16 @@
: !!ccconfig.plot_list_showFootprint; : !!ccconfig.plot_list_showFootprint;
let selectedCluster = filterPresets?.cluster ? filterPresets.cluster : null; let selectedCluster = filterPresets?.cluster ? filterPresets.cluster : null;
let presetProject = filterPresets?.project ? filterPresets.project : "" let presetProject = filterPresets?.project ? filterPresets.project : ""
let showCompare = false;
// The filterPresets are handled by the Filters component, // The filterPresets are handled by the Filters component,
// 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-->
@ -72,10 +82,10 @@
{/if} {/if}
<!-- ROW2: Tools--> <!-- ROW2: Tools-->
<Row cols={{ xs: 1, md: 2, lg: 4}} 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
@ -87,49 +97,88 @@
</Button> </Button>
</ButtonGroup> </ButtonGroup>
</Col> </Col>
<Col lg="4" xl="{(presetProject !== '') ? 5 : 6}" class="mb-1 mb-lg-0"> <Col lg="4" class="mb-1 mb-lg-0">
<Filters <Filters
showFilter={!showCompare}
{filterPresets} {filterPresets}
{matchedJobs} matchedJobs={showCompare? matchedCompareJobs: matchedListJobs}
bind:this={filterComponent} bind:this={filterComponent}
on:update-filters={({ detail }) => { on:update-filters={({ detail }) => {
selectedCluster = detail.filters[0]?.cluster selectedCluster = detail.filters[0]?.cluster
? detail.filters[0].cluster.eq ? detail.filters[0].cluster.eq
: null; : null;
jobList.queryJobs(detail.filters); filterBuffer = [...detail.filters]
if (showCompare) {
jobCompare.queryJobs(detail.filters);
} else {
jobList.queryJobs(detail.filters);
}
}} }}
/> />
</Col> </Col>
<Col lg="3" xl="{(presetProject !== '') ? 3 : 2}" class="mb-2 mb-lg-0"> <Col lg="2" class="mb-2 mb-lg-0">
<TextFilter {#if !showCompare}
{presetProject} <TextFilter
bind:authlevel {presetProject}
bind:roles bind:authlevel
on:set-filter={({ detail }) => filterComponent.updateFilters(detail)} bind:roles
/> on:set-filter={({ detail }) => filterComponent.updateFilters(detail)}
/>
{/if}
</Col> </Col>
<Col lg="3" xl="2" class="mb-1 mb-lg-0"> <Col lg="2" class="mb-1 mb-lg-0">
<Refresher on:refresh={() => { {#if !showCompare}
jobList.refreshJobs() <Refresher on:refresh={() => {
jobList.refreshAllMetrics() jobList.refreshJobs()
}} /> jobList.refreshAllMetrics()
}} />
{/if}
</Col>
<Col lg="2" class="mb-2 mb-lg-0">
<ButtonGroup class="w-100">
<Button color="primary" disabled={matchedListJobs >= 500 && !(selectedJobs.length != 0)} on:click={() => {
if (selectedJobs.length != 0) filterComponent.updateFilters({dbId: selectedJobs}, true)
showCompare = !showCompare
}} >
{showCompare ? 'Return to List' :
'Compare Jobs' + (selectedJobs.length != 0 ? ` (${selectedJobs.length} selected)` : matchedListJobs >= 500 ? ` (Too Many)` : ``)}
</Button>
{#if !showCompare && selectedJobs.length != 0}
<Button color="warning" on:click={() => {
selectedJobs = [] // Only empty array, filters handled by reactive reset
}}>
Clear
</Button>
{/if}
</ButtonGroup>
</Col> </Col>
</Row> </Row>
<!-- ROW3: Job List--> <!-- ROW3: Job List / Job Compare-->
<Row> <Row>
<Col> <Col>
<JobList {#if !showCompare}
bind:this={jobList} <JobList
bind:metrics bind:this={jobList}
bind:sorting bind:metrics
bind:matchedJobs bind:sorting
bind:showFootprint bind:matchedListJobs
/> bind:showFootprint
bind:selectedJobs
{filterBuffer}
/>
{:else}
<JobCompare
bind:this={jobCompare}
bind:metrics
bind:matchedCompareJobs
{filterBuffer}
/>
{/if}
</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

@ -44,6 +44,7 @@
export let disableClusterSelection = false; export let disableClusterSelection = false;
export let startTimeQuickSelect = false; export let startTimeQuickSelect = false;
export let matchedJobs = -2; export let matchedJobs = -2;
export let showFilter = true;
const startTimeSelectOptions = [ const startTimeSelectOptions = [
{ range: "", rangeLabel: "No Selection"}, { range: "", rangeLabel: "No Selection"},
@ -58,6 +59,39 @@
contains: " Contains", contains: " Contains",
} }
const filterReset = {
projectMatch: "contains",
userMatch: "contains",
jobIdMatch: "eq",
nodeMatch: "eq",
cluster: null,
partition: null,
states: allJobStates,
startTime: { from: null, to: null, range: ""},
tags: [],
duration: {
lessThan: null,
moreThan: null,
from: null,
to: null,
},
dbId: [],
jobId: "",
arrayJobId: null,
user: "",
project: "",
jobName: "",
node: null,
energy: { from: null, to: null },
numNodes: { from: null, to: null },
numHWThreads: { from: null, to: null },
numAccelerators: { from: null, to: null },
stats: [],
};
let filters = { let filters = {
projectMatch: filterPresets.projectMatch || "contains", projectMatch: filterPresets.projectMatch || "contains",
userMatch: filterPresets.userMatch || "contains", userMatch: filterPresets.userMatch || "contains",
@ -78,6 +112,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 || "",
@ -106,10 +141,17 @@
isAccsModified = false; isAccsModified = false;
// Can be called from the outside to trigger a 'update' event from this component. // Can be called from the outside to trigger a 'update' event from this component.
export function updateFilters(additionalFilters = null) { // 'force' option empties existing filters and then applies only 'additionalFilters'
if (additionalFilters != null) export function updateFilters(additionalFilters = null, force = false) {
// Empty Current Filter For Force
if (additionalFilters != null && force) {
filters = {...filterReset}
}
// Add Additional Filters
if (additionalFilters != null) {
for (let key in additionalFilters) filters[key] = additionalFilters[key]; for (let key in additionalFilters) filters[key] = additionalFilters[key];
}
// Construct New Filter
let items = []; let items = [];
if (filters.cluster) items.push({ cluster: { eq: filters.cluster } }); if (filters.cluster) items.push({ cluster: { eq: filters.cluster } });
if (filters.node) items.push({ node: { [filters.nodeMatch]: filters.node } }); if (filters.node) items.push({ node: { [filters.nodeMatch]: filters.node } });
@ -137,6 +179,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,8 +224,8 @@
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}`);
if (filters.node && filters.nodeMatch != "eq") // "eq" is default-case if (filters.node && filters.nodeMatch != "eq") // "eq" is default-case
@ -196,6 +240,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}`);
@ -237,8 +286,8 @@
for (let stat of filters.stats) { for (let stat of filters.stats) {
opts.push(`stat=${stat.field}-${stat.from}-${stat.to}`); opts.push(`stat=${stat.field}-${stat.from}-${stat.to}`);
} }
if (opts.length == 0 && window.location.search.length <= 1) return;
if (opts.length == 0 && window.location.search.length <= 1) return;
let newurl = `${window.location.pathname}?${opts.join("&")}`; let newurl = `${window.location.pathname}?${opts.join("&")}`;
window.history.replaceState(null, "", newurl); window.history.replaceState(null, "", newurl);
} }
@ -246,60 +295,63 @@
<!-- Dropdown-Button --> <!-- Dropdown-Button -->
<ButtonGroup> <ButtonGroup>
<ButtonDropdown class="cc-dropdown-on-hover mb-1" style="{(matchedJobs >= -1) ? '' : 'margin-right: 0.5rem;'}"> {#if showFilter}
<DropdownToggle outline caret color="success"> <ButtonDropdown class="cc-dropdown-on-hover mb-1" style="{(matchedJobs >= -1) ? '' : 'margin-right: 0.5rem;'}">
<Icon name="sliders" /> <DropdownToggle outline caret color="success">
Filters <Icon name="sliders" />
</DropdownToggle> Filters
<DropdownMenu> </DropdownToggle>
<DropdownItem header>Manage Filters</DropdownItem> <DropdownMenu>
{#if menuText} <DropdownItem header>Manage Filters</DropdownItem>
<DropdownItem disabled>{menuText}</DropdownItem> {#if menuText}
<DropdownItem divider /> <DropdownItem disabled>{menuText}</DropdownItem>
{/if} <DropdownItem divider />
<DropdownItem on:click={() => (isClusterOpen = true)}> {/if}
<Icon name="cpu" /> Cluster/Partition <DropdownItem on:click={() => (isClusterOpen = true)}>
</DropdownItem> <Icon name="cpu" /> Cluster/Partition
<DropdownItem on:click={() => (isJobStatesOpen = true)}> </DropdownItem>
<Icon name="gear-fill" /> Job States <DropdownItem on:click={() => (isJobStatesOpen = true)}>
</DropdownItem> <Icon name="gear-fill" /> Job States
<DropdownItem on:click={() => (isStartTimeOpen = true)}> </DropdownItem>
<Icon name="calendar-range" /> Start Time <DropdownItem on:click={() => (isStartTimeOpen = true)}>
</DropdownItem> <Icon name="calendar-range" /> Start Time
<DropdownItem on:click={() => (isDurationOpen = true)}> </DropdownItem>
<Icon name="stopwatch" /> Duration <DropdownItem on:click={() => (isDurationOpen = true)}>
</DropdownItem> <Icon name="stopwatch" /> Duration
<DropdownItem on:click={() => (isTagsOpen = true)}> </DropdownItem>
<Icon name="tags" /> Tags <DropdownItem on:click={() => (isTagsOpen = true)}>
</DropdownItem> <Icon name="tags" /> Tags
<DropdownItem on:click={() => (isResourcesOpen = true)}> </DropdownItem>
<Icon name="hdd-stack" /> Resources <DropdownItem on:click={() => (isResourcesOpen = true)}>
</DropdownItem> <Icon name="hdd-stack" /> Resources
<DropdownItem on:click={() => (isEnergyOpen = true)}> </DropdownItem>
<Icon name="lightning-charge-fill" /> Energy <DropdownItem on:click={() => (isEnergyOpen = true)}>
</DropdownItem> <Icon name="lightning-charge-fill" /> Energy
<DropdownItem on:click={() => (isStatsOpen = true)}> </DropdownItem>
<Icon name="bar-chart" on:click={() => (isStatsOpen = true)} /> Statistics <DropdownItem on:click={() => (isStatsOpen = true)}>
</DropdownItem> <Icon name="bar-chart" on:click={() => (isStatsOpen = true)} /> Statistics
{#if startTimeQuickSelect} </DropdownItem>
<DropdownItem divider /> {#if startTimeQuickSelect}
<DropdownItem disabled>Start Time Quick Selection</DropdownItem> <DropdownItem divider />
{#each startTimeSelectOptions.filter((stso) => stso.range !== "") as { rangeLabel, range }} <DropdownItem disabled>Start Time Quick Selection</DropdownItem>
<DropdownItem {#each startTimeSelectOptions.filter((stso) => stso.range !== "") as { rangeLabel, range }}
on:click={() => { <DropdownItem
filters.startTime.from = null on:click={() => {
filters.startTime.to = null filters.startTime.from = null
filters.startTime.range = range; filters.startTime.to = null
updateFilters(); filters.startTime.range = range;
}} updateFilters();
> }}
<Icon name="calendar-range" /> >
{rangeLabel} <Icon name="calendar-range" />
</DropdownItem> {rangeLabel}
{/each} </DropdownItem>
{/if} {/each}
</DropdownMenu> {/if}
</ButtonDropdown> </DropdownMenu>
</ButtonDropdown>
{/if}
{#if matchedJobs >= -1} {#if matchedJobs >= -1}
<Button class="mb-1" style="margin-right: 0.5rem;" disabled outline> <Button class="mb-1" style="margin-right: 0.5rem;" disabled outline>
{matchedJobs == -1 ? 'Loading ...' : `${matchedJobs} jobs`} {matchedJobs == -1 ? 'Loading ...' : `${matchedJobs} jobs`}
@ -307,109 +359,111 @@
{/if} {/if}
</ButtonGroup> </ButtonGroup>
<!-- SELECTED FILTER PILLS --> {#if showFilter}
{#if filters.cluster} <!-- SELECTED FILTER PILLS -->
<Info icon="cpu" on:click={() => (isClusterOpen = true)}> {#if filters.cluster}
{filters.cluster} <Info icon="cpu" on:click={() => (isClusterOpen = true)}>
{#if filters.partition} {filters.cluster}
({filters.partition}) {#if filters.partition}
{/if} ({filters.partition})
</Info> {/if}
{/if} </Info>
{/if}
{#if filters.states.length != allJobStates.length} {#if filters.states.length != allJobStates.length}
<Info icon="gear-fill" on:click={() => (isJobStatesOpen = true)}> <Info icon="gear-fill" on:click={() => (isJobStatesOpen = true)}>
{filters.states.join(", ")} {filters.states.join(", ")}
</Info> </Info>
{/if} {/if}
{#if filters.startTime.from || filters.startTime.to} {#if filters.startTime.from || filters.startTime.to}
<Info icon="calendar-range" on:click={() => (isStartTimeOpen = true)}> <Info icon="calendar-range" on:click={() => (isStartTimeOpen = true)}>
{new Date(filters.startTime.from).toLocaleString()} - {new Date( {new Date(filters.startTime.from).toLocaleString()} - {new Date(
filters.startTime.to, filters.startTime.to,
).toLocaleString()} ).toLocaleString()}
</Info> </Info>
{/if} {/if}
{#if filters.startTime.range} {#if filters.startTime.range}
<Info icon="calendar-range" on:click={() => (isStartTimeOpen = true)}> <Info icon="calendar-range" on:click={() => (isStartTimeOpen = true)}>
{startTimeSelectOptions.find((stso) => stso.range === filters.startTime.range).rangeLabel } {startTimeSelectOptions.find((stso) => stso.range === filters.startTime.range).rangeLabel }
</Info> </Info>
{/if} {/if}
{#if filters.duration.from || filters.duration.to} {#if filters.duration.from || filters.duration.to}
<Info icon="stopwatch" on:click={() => (isDurationOpen = true)}> <Info icon="stopwatch" on:click={() => (isDurationOpen = true)}>
{Math.floor(filters.duration.from / 3600)}h:{Math.floor( {Math.floor(filters.duration.from / 3600)}h:{Math.floor(
(filters.duration.from % 3600) / 60, (filters.duration.from % 3600) / 60,
)}m - )}m -
{Math.floor(filters.duration.to / 3600)}h:{Math.floor( {Math.floor(filters.duration.to / 3600)}h:{Math.floor(
(filters.duration.to % 3600) / 60, (filters.duration.to % 3600) / 60,
)}m )}m
</Info> </Info>
{/if} {/if}
{#if filters.duration.lessThan} {#if filters.duration.lessThan}
<Info icon="stopwatch" on:click={() => (isDurationOpen = true)}> <Info icon="stopwatch" on:click={() => (isDurationOpen = true)}>
Duration less than {Math.floor( Duration less than {Math.floor(
filters.duration.lessThan / 3600, filters.duration.lessThan / 3600,
)}h:{Math.floor((filters.duration.lessThan % 3600) / 60)}m )}h:{Math.floor((filters.duration.lessThan % 3600) / 60)}m
</Info> </Info>
{/if} {/if}
{#if filters.duration.moreThan} {#if filters.duration.moreThan}
<Info icon="stopwatch" on:click={() => (isDurationOpen = true)}> <Info icon="stopwatch" on:click={() => (isDurationOpen = true)}>
Duration more than {Math.floor( Duration more than {Math.floor(
filters.duration.moreThan / 3600, filters.duration.moreThan / 3600,
)}h:{Math.floor((filters.duration.moreThan % 3600) / 60)}m )}h:{Math.floor((filters.duration.moreThan % 3600) / 60)}m
</Info> </Info>
{/if} {/if}
{#if filters.tags.length != 0} {#if filters.tags.length != 0}
<Info icon="tags" on:click={() => (isTagsOpen = true)}> <Info icon="tags" on:click={() => (isTagsOpen = true)}>
{#each filters.tags as tagId} {#each filters.tags as tagId}
{#key tagId} {#key tagId}
<Tag id={tagId} clickable={false} /> <Tag id={tagId} clickable={false} />
{/key} {/key}
{/each} {/each}
</Info> </Info>
{/if} {/if}
{#if filters.numNodes.from != null || filters.numNodes.to != null || filters.numHWThreads.from != null || filters.numHWThreads.to != null || filters.numAccelerators.from != null || filters.numAccelerators.to != null} {#if filters.numNodes.from != null || filters.numNodes.to != null || filters.numHWThreads.from != null || filters.numHWThreads.to != null || filters.numAccelerators.from != null || filters.numAccelerators.to != null}
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}> <Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
{#if isNodesModified} {#if isNodesModified}
Nodes: {filters.numNodes.from} - {filters.numNodes.to} Nodes: {filters.numNodes.from} - {filters.numNodes.to}
{/if} {/if}
{#if isNodesModified && isHwthreadsModified}, {#if isNodesModified && isHwthreadsModified},
{/if} {/if}
{#if isHwthreadsModified} {#if isHwthreadsModified}
HWThreads: {filters.numHWThreads.from} - {filters.numHWThreads.to} HWThreads: {filters.numHWThreads.from} - {filters.numHWThreads.to}
{/if} {/if}
{#if (isNodesModified || isHwthreadsModified) && isAccsModified}, {#if (isNodesModified || isHwthreadsModified) && isAccsModified},
{/if} {/if}
{#if isAccsModified} {#if isAccsModified}
Accelerators: {filters.numAccelerators.from} - {filters.numAccelerators.to} Accelerators: {filters.numAccelerators.from} - {filters.numAccelerators.to}
{/if} {/if}
</Info> </Info>
{/if} {/if}
{#if filters.node != null} {#if filters.node != null}
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}> <Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
Node{nodeMatchLabels[filters.nodeMatch]}: {filters.node} Node{nodeMatchLabels[filters.nodeMatch]}: {filters.node}
</Info> </Info>
{/if} {/if}
{#if filters.energy.from || filters.energy.to} {#if filters.energy.from || filters.energy.to}
<Info icon="lightning-charge-fill" on:click={() => (isEnergyOpen = true)}> <Info icon="lightning-charge-fill" on:click={() => (isEnergyOpen = true)}>
Total Energy: {filters.energy.from} - {filters.energy.to} Total Energy: {filters.energy.from} - {filters.energy.to}
</Info> </Info>
{/if} {/if}
{#if filters.stats.length > 0} {#if filters.stats.length > 0}
<Info icon="bar-chart" on:click={() => (isStatsOpen = true)}> <Info icon="bar-chart" on:click={() => (isStatsOpen = true)}>
{filters.stats {filters.stats
.map((stat) => `${stat.field}: ${stat.from} - ${stat.to}`) .map((stat) => `${stat.field}: ${stat.from} - ${stat.to}`)
.join(", ")} .join(", ")}
</Info> </Info>
{/if}
{/if} {/if}
<Cluster <Cluster

View File

@ -0,0 +1,394 @@
<!--
@component jobCompare component; compares jobs according to set filters or job selection
Properties:
- `matchedJobs Number?`: Number of matched jobs for selected filters [Default: 0]
- `metrics [String]?`: The currently selected metrics [Default: User-Configured Selection]
- `showFootprint Bool`: If to display the jobFootprint component
Functions:
- `queryJobs(filters?: [JobFilter])`: Load jobs data with new filters, starts from page 1
-->
<script>
import { getContext } from "svelte";
import uPlot from "uplot";
import {
queryStore,
gql,
getContextClient,
// mutationStore,
} from "@urql/svelte";
import { Row, Col, Card, Spinner, Table, Input, InputGroup, InputGroupText, Icon } from "@sveltestrap/sveltestrap";
import { formatTime, roundTwoDigits } from "./units.js";
import Comparogram from "./plots/Comparogram.svelte";
const ccconfig = getContext("cc-config"),
// initialized = getContext("initialized"),
globalMetrics = getContext("globalMetrics");
export let matchedCompareJobs = 0;
export let metrics = ccconfig.plot_list_selectedMetrics;
export let filterBuffer = [];
let filter = [...filterBuffer] || [];
let comparePlotData = {};
let compareTableData = [];
let compareTableSorting = {};
let jobIds = [];
let jobClusters = [];
let tableJobIDFilter = "";
/*uPlot*/
let plotSync = uPlot.sync("compareJobsView");
/* GQL */
const client = getContextClient();
// Pull All Series For Metrics Statistics Only On Node Scope
const compareQuery = gql`
query ($filter: [JobFilter!]!, $metrics: [String!]!) {
jobsMetricStats(filter: $filter, metrics: $metrics) {
id
jobId
startTime
duration
cluster
subCluster
numNodes
numHWThreads
numAccelerators
stats {
name
data {
min
avg
max
}
}
}
}
`;
/* REACTIVES */
$: compareData = queryStore({
client: client,
query: compareQuery,
variables:{ filter, metrics },
});
$: matchedCompareJobs = $compareData.data != null ? $compareData.data.jobsMetricStats.length : -1;
$: if ($compareData.data != null) {
jobIds = [];
jobClusters = [];
comparePlotData = {};
compareTableData = [...$compareData.data.jobsMetricStats];
jobs2uplot($compareData.data.jobsMetricStats, metrics);
}
$: if ((!$compareData.fetching && !$compareData.error) && metrics) {
// Meta
compareTableSorting['meta'] = {
startTime: { dir: "down", active: true },
duration: { dir: "up", active: false },
cluster: { dir: "up", active: false },
};
// Resources
compareTableSorting['resources'] = {
Nodes: { dir: "up", active: false },
Threads: { dir: "up", active: false },
Accs: { dir: "up", active: false },
};
// Metrics
for (let metric of metrics) {
compareTableSorting[metric] = {
min: { dir: "up", active: false },
avg: { dir: "up", active: false },
max: { dir: "up", active: false },
};
}
}
/* FUNCTIONS */
// (Re-)query and optionally set new filters; Query will be started reactively.
export function queryJobs(filters) {
if (filters != null) {
let minRunningFor = ccconfig.plot_list_hideShortRunningJobs;
if (minRunningFor && minRunningFor > 0) {
filters.push({ minRunningFor });
}
filter = filters;
}
}
function sortBy(key, field) {
let s = compareTableSorting[key][field];
if (s.active) {
s.dir = s.dir == "up" ? "down" : "up";
} else {
for (let key in compareTableSorting)
for (let field in compareTableSorting[key]) compareTableSorting[key][field].active = false;
s.active = true;
}
compareTableSorting = { ...compareTableSorting };
if (key == 'resources') {
let longField = "";
switch (field) {
case "Nodes":
longField = "numNodes"
break
case "Threads":
longField = "numHWThreads"
break
case "Accs":
longField = "numAccelerators"
break
default:
console.log("Unknown Res Field", field)
}
compareTableData = compareTableData.sort((j1, j2) => {
if (j1[longField] == null || j2[longField] == null) return -1;
return s.dir != "up" ? j1[longField] - j2[longField] : j2[longField] - j1[longField];
});
} else if (key == 'meta') {
compareTableData = compareTableData.sort((j1, j2) => {
if (j1[field] == null || j2[field] == null) return -1;
if (field == 'cluster') {
let c1 = `${j1.cluster} (${j1.subCluster})`
let c2 = `${j2.cluster} (${j2.subCluster})`
return s.dir != "up" ? c1.localeCompare(c2) : c2.localeCompare(c1)
} else {
return s.dir != "up" ? j1[field] - j2[field] : j2[field] - j1[field];
}
});
} else {
compareTableData = compareTableData.sort((j1, j2) => {
let s1 = j1.stats.find((m) => m.name == key)?.data;
let s2 = j2.stats.find((m) => m.name == key)?.data;
if (s1 == null || s2 == null) return -1;
return s.dir != "up" ? s1[field] - s2[field] : s2[field] - s1[field];
});
}
}
function jobs2uplot(jobs, metrics) {
// Resources Init
comparePlotData['resources'] = {unit:'', data: [[],[],[],[],[],[]]} // data: [X, XST, XRT, YNODES, YTHREADS, YACCS]
// Metric Init
for (let m of metrics) {
// Get Unit
const rawUnit = globalMetrics.find((gm) => gm.name == m)?.unit
const metricUnit = (rawUnit?.prefix ? rawUnit.prefix : "") + (rawUnit?.base ? rawUnit.base : "")
comparePlotData[m] = {unit: metricUnit, data: [[],[],[],[],[],[]]} // data: [X, XST, XRT, YMIN, YAVG, YMAX]
}
// Iterate jobs if exists
if (jobs) {
let plotIndex = 0
jobs.forEach((j) => {
// Collect JobIDs & Clusters for X-Ticks and Legend
jobIds.push(j.jobId)
jobClusters.push(`${j.cluster} ${j.subCluster}`)
// Resources
comparePlotData['resources'].data[0].push(plotIndex)
comparePlotData['resources'].data[1].push(j.startTime)
comparePlotData['resources'].data[2].push(j.duration)
comparePlotData['resources'].data[3].push(j.numNodes)
comparePlotData['resources'].data[4].push(j?.numHWThreads?j.numHWThreads:0)
comparePlotData['resources'].data[5].push(j?.numAccelerators?j.numAccelerators:0)
// Metrics
for (let s of j.stats) {
comparePlotData[s.name].data[0].push(plotIndex)
comparePlotData[s.name].data[1].push(j.startTime)
comparePlotData[s.name].data[2].push(j.duration)
comparePlotData[s.name].data[3].push(s.data.min)
comparePlotData[s.name].data[4].push(s.data.avg)
comparePlotData[s.name].data[5].push(s.data.max)
}
plotIndex++
})
}
}
// Adapt for Persisting Job Selections in DB later down the line
// const updateConfigurationMutation = ({ name, value }) => {
// return mutationStore({
// client: client,
// query: gql`
// mutation ($name: String!, $value: String!) {
// updateConfiguration(name: $name, value: $value)
// }
// `,
// variables: { name, value },
// });
// };
// 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;
// }
// });
// }
</script>
{#if $compareData.fetching}
<Row>
<Col>
<Spinner secondary />
</Col>
</Row>
{:else if $compareData.error}
<Row>
<Col>
<Card body color="danger" class="mb-3"
><h2>{$compareData.error.message}</h2></Card
>
</Col>
</Row>
{:else}
{#key comparePlotData}
<Row>
<Col>
<Comparogram
title={'Compare Resources'}
xlabel="JobIDs"
xticks={jobIds}
xinfo={jobClusters}
ylabel={'Resource Counts'}
data={comparePlotData['resources'].data}
{plotSync}
forResources
/>
</Col>
</Row>
{#each metrics as m}
<Row>
<Col>
<Comparogram
title={`Compare Metric '${m}'`}
xlabel="JobIDs"
xticks={jobIds}
xinfo={jobClusters}
ylabel={m}
metric={m}
yunit={comparePlotData[m].unit}
data={comparePlotData[m].data}
{plotSync}
/>
</Col>
</Row>
{/each}
{/key}
<hr/>
<Card>
<Table hover>
<thead>
<!-- Header Row 1 -->
<tr>
<th style="width:8%; max-width:10%;">JobID</th>
<th>StartTime</th>
<th>Duration</th>
<th>Cluster</th>
<th colspan="3">Resources</th>
{#each metrics as metric}
<th colspan="3">{metric} {comparePlotData[metric]?.unit? `(${comparePlotData[metric]?.unit})` : ''}</th>
{/each}
</tr>
<!-- Header Row 2: Fields -->
<tr>
<th>
<InputGroup size="sm">
<Input type="text" bind:value={tableJobIDFilter}/>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
</InputGroup>
</th>
<th on:click={() => sortBy('meta', 'startTime')}>
Sort
<Icon
name="caret-{compareTableSorting['meta']['startTime'].dir}{compareTableSorting['meta']['startTime']
.active
? '-fill'
: ''}"
/>
</th>
<th on:click={() => sortBy('meta', 'duration')}>
Sort
<Icon
name="caret-{compareTableSorting['meta']['duration'].dir}{compareTableSorting['meta']['duration']
.active
? '-fill'
: ''}"
/>
</th>
<th on:click={() => sortBy('meta', 'cluster')}>
Sort
<Icon
name="caret-{compareTableSorting['meta']['cluster'].dir}{compareTableSorting['meta']['cluster']
.active
? '-fill'
: ''}"
/>
</th>
{#each ["Nodes", "Threads", "Accs"] as res}
<th on:click={() => sortBy('resources', res)}>
{res}
<Icon
name="caret-{compareTableSorting['resources'][res].dir}{compareTableSorting['resources'][res]
.active
? '-fill'
: ''}"
/>
</th>
{/each}
{#each metrics as metric}
{#each ["min", "avg", "max"] as stat}
<th on:click={() => sortBy(metric, stat)}>
{stat.charAt(0).toUpperCase() + stat.slice(1)}
<Icon
name="caret-{compareTableSorting[metric][stat].dir}{compareTableSorting[metric][stat]
.active
? '-fill'
: ''}"
/>
</th>
{/each}
{/each}
</tr>
</thead>
<tbody>
{#each compareTableData.filter((j) => j.jobId.includes(tableJobIDFilter)) as job (job.id)}
<tr>
<td><b><a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a></b></td>
<td>{new Date(job.startTime * 1000).toLocaleString()}</td>
<td>{formatTime(job.duration)}</td>
<td>{job.cluster} ({job.subCluster})</td>
<td>{job.numNodes}</td>
<td>{job.numHWThreads}</td>
<td>{job.numAccelerators}</td>
{#each metrics as metric}
<td>{roundTwoDigits(job.stats.find((s) => s.name == metric).data.min)}</td>
<td>{roundTwoDigits(job.stats.find((s) => s.name == metric).data.avg)}</td>
<td>{roundTwoDigits(job.stats.find((s) => s.name == metric).data.max)}</td>
{/each}
</tr>
{:else}
<tr>
<td colspan={7 + (metrics.length * 3)}><b>No jobs found.</b></td>
</tr>
{/each}
</tbody>
</Table>
</Card>
{/if}

View File

@ -35,15 +35,17 @@
} }
export let sorting = { field: "startTime", type: "col", order: "DESC" }; export let sorting = { field: "startTime", type: "col", order: "DESC" };
export let matchedJobs = 0; export let matchedListJobs = 0;
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 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;
let page = 1; let page = 1;
let paging = { itemsPerPage, page }; let paging = { itemsPerPage, page };
let filter = []; let filter = [...filterBuffer];
let lastFilter = []; let lastFilter = [];
let lastSorting = null; let lastSorting = null;
let triggerMetricRefresh = false; let triggerMetricRefresh = false;
@ -141,7 +143,7 @@
} }
} }
$: matchedJobs = $jobsStore.data != null ? $jobsStore.data.jobs.count : -1; $: matchedListJobs = $jobsStore.data != null ? $jobsStore.data.jobs.count : -1;
// Force refresh list with existing unchanged variables (== usually would not trigger reactivity) // Force refresh list with existing unchanged variables (== usually would not trigger reactivity)
export function refreshJobs() { export function refreshJobs() {
@ -284,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>
@ -310,7 +315,7 @@
bind:page bind:page
{itemsPerPage} {itemsPerPage}
itemText="Jobs" itemText="Jobs"
totalItems={matchedJobs} totalItems={matchedListJobs}
on:update-paging={({ detail }) => { on:update-paging={({ detail }) => {
if (detail.itemsPerPage != itemsPerPage) { if (detail.itemsPerPage != itemsPerPage) {
updateConfiguration(detail.itemsPerPage.toString(), detail.page); updateConfiguration(detail.itemsPerPage.toString(), detail.page);

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,18 +78,39 @@
<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>
<Button id={`${job.cluster}-${job.jobId}-clipboard`} outline color="secondary" size="sm" on:click={clipJobId(job.jobId)} > <span>
{#if displayCheck} {#if showSelect}
<Icon name="clipboard2-check-fill"/> <Button id={`${job.cluster}-${job.jobId}-select`} outline={!isSelected} color={isSelected? `success`: `secondary`} size="sm" class="mr-2"
{:else} on:click={() => {
<Icon name="clipboard2"/> 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} {/if}
</Button> <Button id={`${job.cluster}-${job.jobId}-clipboard`} outline color="secondary" size="sm" on:click={clipJobId(job.jobId)} >
<Tooltip {#if displayCheck}
target={`${job.cluster}-${job.jobId}-clipboard`} <Icon name="clipboard2-check-fill"/>
placement="right"> {:else}
{ displayCheck ? 'Copied!' : 'Copy Job ID to Clipboard' } <Icon name="clipboard2"/>
</Tooltip> {/if}
</Button>
<Tooltip
target={`${job.cluster}-${job.jobId}-clipboard`}
placement="right">
{ displayCheck ? 'Copied!' : 'Copy Job ID to Clipboard' }
</Tooltip>
</span>
</span> </span>
{#if job.metaData?.jobName} {#if job.metaData?.jobName}
{#if job.metaData?.jobName.length <= 25} {#if job.metaData?.jobName.length <= 25}

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

@ -0,0 +1,314 @@
<!--
@component Main plot component, based on uPlot; metricdata values by time
Only width/height should change reactively.
Properties:
- `metric String`: The metric name
- `width Number?`: The plot width [Default: 0]
- `height Number?`: The plot height [Default: 300]
- `data [Array]`: The metric data object
- `cluster String`: Cluster name of the parent job / data
- `subCluster String`: Name of the subCluster of the parent job
-->
<script>
import uPlot from "uplot";
import { roundTwoDigits, formatTime, formatNumber } from "../units.js";
import { getContext, onMount, onDestroy } from "svelte";
import { Card } from "@sveltestrap/sveltestrap";
export let metric = "";
export let width = 0;
export let height = 300;
export let data = null;
export let xlabel = "";
export let xticks = [];
export let xinfo = [];
export let ylabel = "";
export let yunit = "";
export let title = "";
export let forResources = false;
export let plotSync;
// NOTE: Metric Thresholds non-required, Cluster Mixing Allowed
const clusterCockpitConfig = getContext("cc-config");
const lineWidth = clusterCockpitConfig.plot_general_lineWidth / window.devicePixelRatio;
const cbmode = clusterCockpitConfig?.plot_general_colorblindMode || false;
// UPLOT PLUGIN // converts the legend into a simple tooltip
function legendAsTooltipPlugin({
className,
style = { backgroundColor: "rgba(255, 249, 196, 0.92)", color: "black" },
} = {}) {
let legendEl;
function init(u, opts) {
legendEl = u.root.querySelector(".u-legend");
legendEl.classList.remove("u-inline");
className && legendEl.classList.add(className);
uPlot.assign(legendEl.style, {
minWidth: "100px",
textAlign: "left",
pointerEvents: "none",
display: "none",
position: "absolute",
left: 0,
top: 0,
zIndex: 100,
boxShadow: "2px 2px 10px rgba(0,0,0,0.5)",
...style,
});
// hide series color markers:
const idents = legendEl.querySelectorAll(".u-marker");
for (let i = 0; i < idents.length; i++)
idents[i].style.display = "none";
const overEl = u.over;
overEl.style.overflow = "visible";
// move legend into plot bounds
overEl.appendChild(legendEl);
// show/hide tooltip on enter/exit
overEl.addEventListener("mouseenter", () => {
legendEl.style.display = null;
});
overEl.addEventListener("mouseleave", () => {
legendEl.style.display = "none";
});
}
function update(u) {
const { left, top } = u.cursor;
const width = u?.over?.querySelector(".u-legend")?.offsetWidth ? u.over.querySelector(".u-legend").offsetWidth : 0;
legendEl.style.transform =
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)";
}
return {
hooks: {
init: init,
setCursor: update,
},
};
}
const plotSeries = [
{
label: "JobID",
scale: "x",
value: (u, ts, sidx, didx) => {
return `${xticks[didx]} | ${xinfo[didx]}`;
},
},
{
label: "Starttime",
scale: "xst",
value: (u, ts, sidx, didx) => {
return new Date(ts * 1000).toLocaleString();
},
},
{
label: "Duration",
scale: "xrt",
value: (u, ts, sidx, didx) => {
return formatTime(ts);
},
},
]
if (forResources) {
const resSeries = [
{
label: "Nodes",
scale: "y",
width: lineWidth,
stroke: "black",
},
{
label: "Threads",
scale: "y",
width: lineWidth,
stroke: "rgb(0,0,255)",
},
{
label: "Accelerators",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(0,255,0)" : "red",
}
];
plotSeries.push(...resSeries)
} else {
const statsSeries = [
{
label: "Min",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(0,255,0)" : "red",
value: (u, ts, sidx, didx) => {
return `${roundTwoDigits(ts)} ${yunit}`;
},
},
{
label: "Avg",
scale: "y",
width: lineWidth,
stroke: "black",
value: (u, ts, sidx, didx) => {
return `${roundTwoDigits(ts)} ${yunit}`;
},
},
{
label: "Max",
scale: "y",
width: lineWidth,
stroke: cbmode ? "rgb(0,0,255)" : "green",
value: (u, ts, sidx, didx) => {
return `${roundTwoDigits(ts)} ${yunit}`;
},
}
];
plotSeries.push(...statsSeries)
};
const plotBands = [
{ series: [5, 4], fill: cbmode ? "rgba(0,0,255,0.1)" : "rgba(0,255,0,0.1)" },
{ series: [4, 3], fill: cbmode ? "rgba(0,255,0,0.1)" : "rgba(255,0,0,0.1)" },
];
const opts = {
width,
height,
title,
plugins: [legendAsTooltipPlugin()],
series: plotSeries,
axes: [
{
scale: "x",
space: 25, // Tick Spacing
rotate: 30,
show: true,
label: xlabel,
values(self, splits) {
return splits.map(s => xticks[s]);
}
},
{
scale: "xst",
show: false,
},
{
scale: "xrt",
show: false,
},
{
scale: "y",
grid: { show: true },
labelFont: "sans-serif",
label: ylabel + (yunit ? ` (${yunit})` : ''),
values: (u, vals) => vals.map((v) => formatNumber(v)),
},
],
bands: forResources ? [] : plotBands,
padding: [5, 10, 0, 0],
hooks: {
draw: [
(u) => {
// Draw plot type label:
let textl = forResources ? "Job Resources by Type" : "Metric Min/Avg/Max for Job Duration";
let textr = "Earlier <- StartTime -> Later";
u.ctx.save();
u.ctx.textAlign = "start";
u.ctx.fillStyle = "black";
u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + 10);
u.ctx.textAlign = "end";
u.ctx.fillStyle = "black";
u.ctx.fillText(
textr,
u.bbox.left + u.bbox.width - 10,
u.bbox.top + 10,
);
u.ctx.restore();
return;
},
]
},
scales: {
x: { time: false },
xst: { time: false },
xrt: { time: false },
y: {auto: true, distr: forResources ? 3 : 1},
},
legend: {
// Display legend
show: true,
live: true,
},
cursor: {
drag: { x: true, y: true },
sync: {
key: plotSync.key,
scales: ["x", null],
}
}
};
// RENDER HANDLING
let plotWrapper = null;
let uplot = null;
let timeoutId = null;
function render(ren_width, ren_height) {
if (!uplot) {
opts.width = ren_width;
opts.height = ren_height;
uplot = new uPlot(opts, data, plotWrapper); // Data is uplot formatted [[X][Ymin][Yavg][Ymax]]
plotSync.sub(uplot)
} else {
uplot.setSize({ width: ren_width, height: ren_height });
}
}
function onSizeChange(chg_width, chg_height) {
if (!uplot) return;
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
render(chg_width, chg_height);
}, 200);
}
onMount(() => {
if (plotWrapper) {
render(width, height);
}
});
onDestroy(() => {
if (timeoutId != null) clearTimeout(timeoutId);
if (uplot) uplot.destroy();
});
// This updates plot on all size changes if wrapper (== data) exists
$: if (plotWrapper) {
onSizeChange(width, height);
}
</script>
<!-- Define $width Wrapper and NoData Card -->
{#if data && data[0].length > 0}
<div bind:this={plotWrapper} bind:clientWidth={width}
style="background-color: rgba(255, 255, 255, 1.0);" class="rounded"
/>
{:else}
<Card body color="warning" class="mx-4 my-2"
>Cannot render plot: No series data returned for <code>{metric?metric:'job resources'}</code></Card
>
{/if}

View File

@ -16,7 +16,7 @@
<script> <script>
import uPlot from "uplot"; import uPlot from "uplot";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { formatNumber } from "../units.js"; import { formatNumber, formatTime } from "../units.js";
import { Card } from "@sveltestrap/sveltestrap"; import { Card } from "@sveltestrap/sveltestrap";
export let data; export let data;
@ -36,21 +36,6 @@
points: 2, points: 2,
}; };
function formatTime(t) {
if (t !== null) {
if (isNaN(t)) {
return t;
} else {
const tAbs = Math.abs(t);
const h = Math.floor(tAbs / 3600);
const m = Math.floor((tAbs % 3600) / 60);
if (h == 0) return `${m}m`;
else if (m == 0) return `${h}h`;
else return `${h}:${m}h`;
}
}
}
function paths(u, seriesIdx, idx0, idx1, extendGap, buildClip) { function paths(u, seriesIdx, idx0, idx1, extendGap, buildClip) {
let s = u.series[seriesIdx]; let s = u.series[seriesIdx];
let style = s.drawStyle; let style = s.drawStyle;

View File

@ -21,22 +21,6 @@
--> -->
<script context="module"> <script context="module">
function formatTime(t, forNode = false) {
if (t !== null) {
if (isNaN(t)) {
return t;
} else {
const tAbs = Math.abs(t);
const h = Math.floor(tAbs / 3600);
const m = Math.floor((tAbs % 3600) / 60);
// Re-Add "negativity" to time ticks only as string, so that if-cases work as intended
if (h == 0) return `${forNode && m != 0 ? "-" : ""}${m}m`;
else if (m == 0) return `${forNode ? "-" : ""}${h}h`;
else return `${forNode ? "-" : ""}${h}:${m}h`;
}
}
}
function timeIncrs(timestep, maxX, forNode) { function timeIncrs(timestep, maxX, forNode) {
if (forNode === true) { if (forNode === true) {
return [60, 120, 240, 300, 360, 480, 600, 900, 1800, 3600, 7200, 14400, 21600]; // forNode fixed increments return [60, 120, 240, 300, 360, 480, 600, 900, 1800, 3600, 7200, 14400, 21600]; // forNode fixed increments
@ -118,7 +102,7 @@
<script> <script>
import uPlot from "uplot"; import uPlot from "uplot";
import { formatNumber } from "../units.js"; import { formatNumber, formatTime } from "../units.js";
import { getContext, onMount, onDestroy, createEventDispatcher } from "svelte"; import { getContext, onMount, onDestroy, createEventDispatcher } from "svelte";
import { Card } from "@sveltestrap/sveltestrap"; import { Card } from "@sveltestrap/sveltestrap";

View File

@ -55,7 +55,7 @@
const getValues = (type) => labels.map(name => { const getValues = (type) => labels.map(name => {
// Peak is adapted and scaled for job shared state // Peak is adapted and scaled for job shared state
const peak = polarMetrics.find(m => m?.name == name)?.peak const peak = polarMetrics.find(m => m?.name == name)?.peak
const metric = polarData.find(m => m?.name == name)?.stats const metric = polarData.find(m => m?.name == name)?.data
const value = (peak && metric) ? (metric[type] / peak) : 0 const value = (peak && metric) ? (metric[type] / peak) : 0
return value <= 1. ? value : 1. return value <= 1. ? value : 1.
}) })

View File

@ -17,6 +17,10 @@ export function formatNumber(x) {
} }
} }
export function roundTwoDigits(x) {
return Math.round(x * 100) / 100
}
export function scaleNumbers(x, y , p = '') { export function scaleNumbers(x, y , p = '') {
const oldPower = power[prefix.indexOf(p)] const oldPower = power[prefix.indexOf(p)]
const rawXValue = x * oldPower const rawXValue = x * oldPower
@ -31,4 +35,20 @@ export function scaleNumbers(x, y , p = '') {
return Math.abs(rawYValue) >= 1000 ? `${rawXValue.toExponential()} / ${rawYValue.toExponential()}` : `${rawYValue.toString()} / ${rawYValue.toString()}` return Math.abs(rawYValue) >= 1000 ? `${rawXValue.toExponential()} / ${rawYValue.toExponential()}` : `${rawYValue.toString()} / ${rawYValue.toString()}`
} }
export function formatTime(t, forNode = false) {
if (t !== null) {
if (isNaN(t)) {
return t;
} else {
const tAbs = Math.abs(t);
const h = Math.floor(tAbs / 3600);
const m = Math.floor((tAbs % 3600) / 60);
// Re-Add "negativity" to time ticks only as string, so that if-cases work as intended
if (h == 0) return `${forNode && m != 0 ? "-" : ""}${m}m`;
else if (m == 0) return `${forNode ? "-" : ""}${h}h`;
else return `${forNode ? "-" : ""}${h}:${m}h`;
}
}
}
// export const dateToUnixEpoch = (rfc3339) => Math.floor(Date.parse(rfc3339) / 1000); // export const dateToUnixEpoch = (rfc3339) => Math.floor(Date.parse(rfc3339) / 1000);

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)

View File

@ -42,7 +42,7 @@
query ($dbid: ID!, $selectedMetrics: [String!]!) { query ($dbid: ID!, $selectedMetrics: [String!]!) {
jobStats(id: $dbid, metrics: $selectedMetrics) { jobStats(id: $dbid, metrics: $selectedMetrics) {
name name
stats { data {
min min
avg avg
max max