mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-05-18 14:47:13 +02:00
Merge pull request #391 from ClusterCockpit/add_job_comparison
Add job comparison
This commit is contained in:
commit
b323ce2eef
@ -158,7 +158,7 @@ type StatsSeries {
|
||||
max: [NullableFloat!]!
|
||||
}
|
||||
|
||||
type JobStatsWithScope {
|
||||
type NamedStatsWithScope {
|
||||
name: String!
|
||||
scope: MetricScope!
|
||||
stats: [ScopedStats!]!
|
||||
@ -171,8 +171,21 @@ type ScopedStats {
|
||||
}
|
||||
|
||||
type JobStats {
|
||||
id: Int!
|
||||
jobId: String!
|
||||
startTime: Int!
|
||||
duration: Int!
|
||||
cluster: String!
|
||||
subCluster: String!
|
||||
numNodes: Int!
|
||||
numHWThreads: Int
|
||||
numAccelerators: Int
|
||||
stats: [NamedStats!]!
|
||||
}
|
||||
|
||||
type NamedStats {
|
||||
name: String!
|
||||
stats: MetricStatistics!
|
||||
data: MetricStatistics!
|
||||
}
|
||||
|
||||
type Unit {
|
||||
@ -259,12 +272,13 @@ type Query {
|
||||
|
||||
job(id: ID!): Job
|
||||
jobMetrics(id: ID!, metrics: [String!], scopes: [MetricScope!], resolution: Int): [JobMetricWithName!]!
|
||||
jobStats(id: ID!, metrics: [String!]): [JobStats!]!
|
||||
scopedJobStats(id: ID!, metrics: [String!], scopes: [MetricScope!]): [JobStatsWithScope!]!
|
||||
jobsFootprints(filter: [JobFilter!], metrics: [String!]!): Footprints
|
||||
jobStats(id: ID!, metrics: [String!]): [NamedStats!]!
|
||||
scopedJobStats(id: ID!, metrics: [String!], scopes: [MetricScope!]): [NamedStatsWithScope!]!
|
||||
|
||||
jobs(filter: [JobFilter!], page: PageRequest, order: OrderByInput): JobResultList!
|
||||
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!]!]!
|
||||
|
||||
@ -287,6 +301,7 @@ type TimeRangeOutput { range: String, from: Time!, to: Time! }
|
||||
|
||||
input JobFilter {
|
||||
tags: [ID!]
|
||||
dbId: [ID!]
|
||||
jobId: StringInput
|
||||
arrayJobId: Int
|
||||
user: StringInput
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -50,6 +50,7 @@ type IntRangeOutput struct {
|
||||
|
||||
type JobFilter struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
DbID []string `json:"dbId,omitempty"`
|
||||
JobID *StringInput `json:"jobId,omitempty"`
|
||||
ArrayJobID *int `json:"arrayJobId,omitempty"`
|
||||
User *StringInput `json:"user,omitempty"`
|
||||
@ -96,14 +97,16 @@ type JobResultList struct {
|
||||
}
|
||||
|
||||
type JobStats struct {
|
||||
Name string `json:"name"`
|
||||
Stats *schema.MetricStatistics `json:"stats"`
|
||||
}
|
||||
|
||||
type JobStatsWithScope struct {
|
||||
Name string `json:"name"`
|
||||
Scope schema.MetricScope `json:"scope"`
|
||||
Stats []*ScopedStats `json:"stats"`
|
||||
ID int `json:"id"`
|
||||
JobID string `json:"jobId"`
|
||||
StartTime int `json:"startTime"`
|
||||
Duration int `json:"duration"`
|
||||
Cluster string `json:"cluster"`
|
||||
SubCluster string `json:"subCluster"`
|
||||
NumNodes int `json:"numNodes"`
|
||||
NumHWThreads *int `json:"numHWThreads,omitempty"`
|
||||
NumAccelerators *int `json:"numAccelerators,omitempty"`
|
||||
Stats []*NamedStats `json:"stats"`
|
||||
}
|
||||
|
||||
type JobsStatistics struct {
|
||||
@ -153,6 +156,17 @@ type MetricStatItem 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 {
|
||||
Host string `json:"host"`
|
||||
SubCluster string `json:"subCluster"`
|
||||
|
@ -400,7 +400,7 @@ func (r *queryResolver) JobMetrics(ctx context.Context, id string, metrics []str
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
res := []*model.JobStats{}
|
||||
res := []*model.NamedStats{}
|
||||
for name, md := range data {
|
||||
res = append(res, &model.JobStats{
|
||||
res = append(res, &model.NamedStats{
|
||||
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.
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
res := make([]*model.JobStatsWithScope, 0)
|
||||
res := make([]*model.NamedStatsWithScope, 0)
|
||||
for name, scoped := range data {
|
||||
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,
|
||||
Scope: scope,
|
||||
Stats: mdlStats,
|
||||
@ -462,12 +462,6 @@ func (r *queryResolver) ScopedJobStats(ctx context.Context, id string, metrics [
|
||||
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.
|
||||
func (r *queryResolver) Jobs(ctx context.Context, filter []*model.JobFilter, page *model.PageRequest, order *model.OrderByInput) (*model.JobResultList, error) {
|
||||
if page == nil {
|
||||
@ -589,6 +583,62 @@ func (r *queryResolver) JobsStatistics(ctx context.Context, filter []*model.JobF
|
||||
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.
|
||||
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)
|
||||
|
@ -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?
|
||||
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 {
|
||||
query = buildStringCondition("job.job_id", filter.JobID, query)
|
||||
}
|
||||
|
@ -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 len(query["jobId"]) == 1 {
|
||||
filterPresets["jobId"] = query.Get("jobId")
|
||||
|
@ -21,6 +21,7 @@
|
||||
import { init } from "./generic/utils.js";
|
||||
import Filters from "./generic/Filters.svelte";
|
||||
import JobList from "./generic/JobList.svelte";
|
||||
import JobCompare from "./generic/JobCompare.svelte";
|
||||
import TextFilter from "./generic/helper/TextFilter.svelte";
|
||||
import Refresher from "./generic/helper/Refresher.svelte";
|
||||
import Sorting from "./generic/select/SortSelection.svelte";
|
||||
@ -35,8 +36,12 @@
|
||||
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 filterBuffer = [];
|
||||
let selectedJobs = [];
|
||||
let jobList,
|
||||
matchedJobs = null;
|
||||
jobCompare,
|
||||
matchedListJobs,
|
||||
matchedCompareJobs = null;
|
||||
let sorting = { field: "startTime", type: "col", order: "DESC" },
|
||||
isSortingOpen = false,
|
||||
isMetricsSelectionOpen = false;
|
||||
@ -49,11 +54,16 @@
|
||||
: !!ccconfig.plot_list_showFootprint;
|
||||
let selectedCluster = filterPresets?.cluster ? filterPresets.cluster : null;
|
||||
let presetProject = filterPresets?.project ? filterPresets.project : ""
|
||||
let showCompare = false;
|
||||
|
||||
// The filterPresets are handled by the Filters component,
|
||||
// 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.
|
||||
onMount(() => filterComponent.updateFilters());
|
||||
|
||||
$: if (filterComponent && selectedJobs.length == 0) {
|
||||
filterComponent.updateFilters({dbId: []})
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ROW1: Status-->
|
||||
@ -72,10 +82,10 @@
|
||||
{/if}
|
||||
|
||||
<!-- 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">
|
||||
<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
|
||||
</Button>
|
||||
<Button
|
||||
@ -87,49 +97,88 @@
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Col>
|
||||
<Col lg="4" xl="{(presetProject !== '') ? 5 : 6}" class="mb-1 mb-lg-0">
|
||||
<Col lg="4" class="mb-1 mb-lg-0">
|
||||
<Filters
|
||||
showFilter={!showCompare}
|
||||
{filterPresets}
|
||||
{matchedJobs}
|
||||
matchedJobs={showCompare? matchedCompareJobs: matchedListJobs}
|
||||
bind:this={filterComponent}
|
||||
on:update-filters={({ detail }) => {
|
||||
selectedCluster = detail.filters[0]?.cluster
|
||||
? detail.filters[0].cluster.eq
|
||||
: null;
|
||||
filterBuffer = [...detail.filters]
|
||||
if (showCompare) {
|
||||
jobCompare.queryJobs(detail.filters);
|
||||
} else {
|
||||
jobList.queryJobs(detail.filters);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col lg="3" xl="{(presetProject !== '') ? 3 : 2}" class="mb-2 mb-lg-0">
|
||||
<Col lg="2" class="mb-2 mb-lg-0">
|
||||
{#if !showCompare}
|
||||
<TextFilter
|
||||
{presetProject}
|
||||
bind:authlevel
|
||||
bind:roles
|
||||
on:set-filter={({ detail }) => filterComponent.updateFilters(detail)}
|
||||
/>
|
||||
{/if}
|
||||
</Col>
|
||||
<Col lg="3" xl="2" class="mb-1 mb-lg-0">
|
||||
<Col lg="2" class="mb-1 mb-lg-0">
|
||||
{#if !showCompare}
|
||||
<Refresher on:refresh={() => {
|
||||
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>
|
||||
</Row>
|
||||
|
||||
<!-- ROW3: Job List-->
|
||||
<!-- ROW3: Job List / Job Compare-->
|
||||
<Row>
|
||||
<Col>
|
||||
{#if !showCompare}
|
||||
<JobList
|
||||
bind:this={jobList}
|
||||
bind:metrics
|
||||
bind:sorting
|
||||
bind:matchedJobs
|
||||
bind:matchedListJobs
|
||||
bind:showFootprint
|
||||
bind:selectedJobs
|
||||
{filterBuffer}
|
||||
/>
|
||||
{:else}
|
||||
<JobCompare
|
||||
bind:this={jobCompare}
|
||||
bind:metrics
|
||||
bind:matchedCompareJobs
|
||||
{filterBuffer}
|
||||
/>
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Sorting bind:sorting bind:isOpen={isSortingOpen} />
|
||||
<Sorting bind:sorting bind:isOpen={isSortingOpen}/>
|
||||
|
||||
<MetricSelection
|
||||
bind:cluster={selectedCluster}
|
||||
|
@ -44,6 +44,7 @@
|
||||
export let disableClusterSelection = false;
|
||||
export let startTimeQuickSelect = false;
|
||||
export let matchedJobs = -2;
|
||||
export let showFilter = true;
|
||||
|
||||
const startTimeSelectOptions = [
|
||||
{ range: "", rangeLabel: "No Selection"},
|
||||
@ -58,6 +59,39 @@
|
||||
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 = {
|
||||
projectMatch: filterPresets.projectMatch || "contains",
|
||||
userMatch: filterPresets.userMatch || "contains",
|
||||
@ -78,6 +112,7 @@
|
||||
from: null,
|
||||
to: null,
|
||||
},
|
||||
dbId: filterPresets.dbId || [],
|
||||
jobId: filterPresets.jobId || "",
|
||||
arrayJobId: filterPresets.arrayJobId || null,
|
||||
user: filterPresets.user || "",
|
||||
@ -106,10 +141,17 @@
|
||||
isAccsModified = false;
|
||||
|
||||
// Can be called from the outside to trigger a 'update' event from this component.
|
||||
export function updateFilters(additionalFilters = null) {
|
||||
if (additionalFilters != null)
|
||||
// 'force' option empties existing filters and then applies only 'additionalFilters'
|
||||
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];
|
||||
|
||||
}
|
||||
// Construct New Filter
|
||||
let items = [];
|
||||
if (filters.cluster) items.push({ cluster: { eq: filters.cluster } });
|
||||
if (filters.node) items.push({ node: { [filters.nodeMatch]: filters.node } });
|
||||
@ -137,6 +179,8 @@
|
||||
items.push({
|
||||
energy: { from: filters.energy.from, to: filters.energy.to },
|
||||
});
|
||||
if (filters.dbId.length != 0)
|
||||
items.push({ dbId: filters.dbId });
|
||||
if (filters.jobId)
|
||||
items.push({ jobId: { [filters.jobIdMatch]: filters.jobId } });
|
||||
if (filters.arrayJobId != null)
|
||||
@ -180,8 +224,8 @@
|
||||
|
||||
function changeURL() {
|
||||
const dateToUnixEpoch = (rfc3339) => Math.floor(Date.parse(rfc3339) / 1000);
|
||||
|
||||
let opts = [];
|
||||
|
||||
if (filters.cluster) opts.push(`cluster=${filters.cluster}`);
|
||||
if (filters.node) opts.push(`node=${filters.node}`);
|
||||
if (filters.node && filters.nodeMatch != "eq") // "eq" is default-case
|
||||
@ -196,6 +240,11 @@
|
||||
if (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.jobIdMatch != "in") {
|
||||
opts.push(`jobId=${filters.jobId}`);
|
||||
@ -237,8 +286,8 @@
|
||||
for (let stat of filters.stats) {
|
||||
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("&")}`;
|
||||
window.history.replaceState(null, "", newurl);
|
||||
}
|
||||
@ -246,6 +295,7 @@
|
||||
|
||||
<!-- Dropdown-Button -->
|
||||
<ButtonGroup>
|
||||
{#if showFilter}
|
||||
<ButtonDropdown class="cc-dropdown-on-hover mb-1" style="{(matchedJobs >= -1) ? '' : 'margin-right: 0.5rem;'}">
|
||||
<DropdownToggle outline caret color="success">
|
||||
<Icon name="sliders" />
|
||||
@ -300,6 +350,8 @@
|
||||
{/if}
|
||||
</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
{/if}
|
||||
|
||||
{#if matchedJobs >= -1}
|
||||
<Button class="mb-1" style="margin-right: 0.5rem;" disabled outline>
|
||||
{matchedJobs == -1 ? 'Loading ...' : `${matchedJobs} jobs`}
|
||||
@ -307,37 +359,38 @@
|
||||
{/if}
|
||||
</ButtonGroup>
|
||||
|
||||
<!-- SELECTED FILTER PILLS -->
|
||||
{#if filters.cluster}
|
||||
{#if showFilter}
|
||||
<!-- SELECTED FILTER PILLS -->
|
||||
{#if filters.cluster}
|
||||
<Info icon="cpu" on:click={() => (isClusterOpen = true)}>
|
||||
{filters.cluster}
|
||||
{#if filters.partition}
|
||||
({filters.partition})
|
||||
{/if}
|
||||
</Info>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if filters.states.length != allJobStates.length}
|
||||
{#if filters.states.length != allJobStates.length}
|
||||
<Info icon="gear-fill" on:click={() => (isJobStatesOpen = true)}>
|
||||
{filters.states.join(", ")}
|
||||
</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)}>
|
||||
{new Date(filters.startTime.from).toLocaleString()} - {new Date(
|
||||
filters.startTime.to,
|
||||
).toLocaleString()}
|
||||
</Info>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if filters.startTime.range}
|
||||
{#if filters.startTime.range}
|
||||
<Info icon="calendar-range" on:click={() => (isStartTimeOpen = true)}>
|
||||
{startTimeSelectOptions.find((stso) => stso.range === filters.startTime.range).rangeLabel }
|
||||
</Info>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if filters.duration.from || filters.duration.to}
|
||||
{#if filters.duration.from || filters.duration.to}
|
||||
<Info icon="stopwatch" on:click={() => (isDurationOpen = true)}>
|
||||
{Math.floor(filters.duration.from / 3600)}h:{Math.floor(
|
||||
(filters.duration.from % 3600) / 60,
|
||||
@ -346,25 +399,25 @@
|
||||
(filters.duration.to % 3600) / 60,
|
||||
)}m
|
||||
</Info>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if filters.duration.lessThan}
|
||||
{#if filters.duration.lessThan}
|
||||
<Info icon="stopwatch" on:click={() => (isDurationOpen = true)}>
|
||||
Duration less than {Math.floor(
|
||||
filters.duration.lessThan / 3600,
|
||||
)}h:{Math.floor((filters.duration.lessThan % 3600) / 60)}m
|
||||
</Info>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if filters.duration.moreThan}
|
||||
{#if filters.duration.moreThan}
|
||||
<Info icon="stopwatch" on:click={() => (isDurationOpen = true)}>
|
||||
Duration more than {Math.floor(
|
||||
filters.duration.moreThan / 3600,
|
||||
)}h:{Math.floor((filters.duration.moreThan % 3600) / 60)}m
|
||||
</Info>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if filters.tags.length != 0}
|
||||
{#if filters.tags.length != 0}
|
||||
<Info icon="tags" on:click={() => (isTagsOpen = true)}>
|
||||
{#each filters.tags as tagId}
|
||||
{#key tagId}
|
||||
@ -372,9 +425,9 @@
|
||||
{/key}
|
||||
{/each}
|
||||
</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)}>
|
||||
{#if isNodesModified}
|
||||
Nodes: {filters.numNodes.from} - {filters.numNodes.to}
|
||||
@ -390,26 +443,27 @@
|
||||
Accelerators: {filters.numAccelerators.from} - {filters.numAccelerators.to}
|
||||
{/if}
|
||||
</Info>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if filters.node != null}
|
||||
{#if filters.node != null}
|
||||
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
|
||||
Node{nodeMatchLabels[filters.nodeMatch]}: {filters.node}
|
||||
</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)}>
|
||||
Total Energy: {filters.energy.from} - {filters.energy.to}
|
||||
</Info>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if filters.stats.length > 0}
|
||||
{#if filters.stats.length > 0}
|
||||
<Info icon="bar-chart" on:click={() => (isStatsOpen = true)}>
|
||||
{filters.stats
|
||||
.map((stat) => `${stat.field}: ${stat.from} - ${stat.to}`)
|
||||
.join(", ")}
|
||||
</Info>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Cluster
|
||||
|
394
web/frontend/src/generic/JobCompare.svelte
Normal file
394
web/frontend/src/generic/JobCompare.svelte
Normal 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}
|
@ -35,15 +35,17 @@
|
||||
}
|
||||
|
||||
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 showFootprint;
|
||||
export let filterBuffer = [];
|
||||
export let selectedJobs = [];
|
||||
|
||||
let usePaging = ccconfig.job_list_usePaging
|
||||
let itemsPerPage = usePaging ? ccconfig.plot_list_jobsPerPage : 10;
|
||||
let page = 1;
|
||||
let paging = { itemsPerPage, page };
|
||||
let filter = [];
|
||||
let filter = [...filterBuffer];
|
||||
let lastFilter = [];
|
||||
let lastSorting = null;
|
||||
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)
|
||||
export function refreshJobs() {
|
||||
@ -284,7 +286,10 @@
|
||||
</tr>
|
||||
{:else}
|
||||
{#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}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}> No jobs found </td>
|
||||
@ -310,7 +315,7 @@
|
||||
bind:page
|
||||
{itemsPerPage}
|
||||
itemText="Jobs"
|
||||
totalItems={matchedJobs}
|
||||
totalItems={matchedListJobs}
|
||||
on:update-paging={({ detail }) => {
|
||||
if (detail.itemsPerPage != itemsPerPage) {
|
||||
updateConfiguration(detail.itemsPerPage.toString(), detail.page);
|
||||
|
@ -18,6 +18,8 @@
|
||||
export let username = null;
|
||||
export let authlevel= null;
|
||||
export let roles = null;
|
||||
export let isSelected = null;
|
||||
export let showSelect = false;
|
||||
|
||||
function formatDuration(duration) {
|
||||
const hours = Math.floor(duration / 3600);
|
||||
@ -76,6 +78,26 @@
|
||||
<a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a>
|
||||
({job.cluster})
|
||||
</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)} >
|
||||
{#if displayCheck}
|
||||
<Icon name="clipboard2-check-fill"/>
|
||||
@ -89,6 +111,7 @@
|
||||
{ displayCheck ? 'Copied!' : 'Copy Job ID to Clipboard' }
|
||||
</Tooltip>
|
||||
</span>
|
||||
</span>
|
||||
{#if job.metaData?.jobName}
|
||||
{#if job.metaData?.jobName.length <= 25}
|
||||
<div>{job.metaData.jobName}</div>
|
||||
|
@ -12,7 +12,7 @@
|
||||
|
||||
<script>
|
||||
import { queryStore, gql, getContextClient } from "@urql/svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { getContext, createEventDispatcher } from "svelte";
|
||||
import { Card, Spinner } from "@sveltestrap/sveltestrap";
|
||||
import { maxScope, checkMetricDisabled } from "../utils.js";
|
||||
import JobInfo from "./JobInfo.svelte";
|
||||
@ -25,7 +25,9 @@
|
||||
export let plotHeight = 275;
|
||||
export let showFootprint;
|
||||
export let triggerMetricRefresh = false;
|
||||
export let previousSelect = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const resampleConfig = getContext("resampling") || null;
|
||||
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
||||
|
||||
@ -39,6 +41,8 @@
|
||||
let zoomStates = {};
|
||||
let thresholdStates = {};
|
||||
|
||||
$: isSelected = previousSelect || null;
|
||||
|
||||
const cluster = getContext("clusters").find((c) => c.name == job.cluster);
|
||||
const client = getContextClient();
|
||||
const query = gql`
|
||||
@ -112,6 +116,12 @@
|
||||
refreshMetrics();
|
||||
}
|
||||
|
||||
$: if (isSelected == true && previousSelect == false) {
|
||||
dispatch("select-job", job.id)
|
||||
} else if (isSelected == false && previousSelect == true) {
|
||||
dispatch("unselect-job", job.id)
|
||||
}
|
||||
|
||||
// Helper
|
||||
const selectScope = (jobMetrics) =>
|
||||
jobMetrics.reduce(
|
||||
@ -152,7 +162,7 @@
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<JobInfo {job} />
|
||||
<JobInfo {job} bind:isSelected showSelect/>
|
||||
</td>
|
||||
{#if job.monitoringStatus == 0 || job.monitoringStatus == 2}
|
||||
<td colspan={metrics.length}>
|
||||
|
314
web/frontend/src/generic/plots/Comparogram.svelte
Normal file
314
web/frontend/src/generic/plots/Comparogram.svelte
Normal 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}
|
@ -16,7 +16,7 @@
|
||||
<script>
|
||||
import uPlot from "uplot";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { formatNumber } from "../units.js";
|
||||
import { formatNumber, formatTime } from "../units.js";
|
||||
import { Card } from "@sveltestrap/sveltestrap";
|
||||
|
||||
export let data;
|
||||
@ -36,21 +36,6 @@
|
||||
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) {
|
||||
let s = u.series[seriesIdx];
|
||||
let style = s.drawStyle;
|
||||
|
@ -21,22 +21,6 @@
|
||||
-->
|
||||
|
||||
<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) {
|
||||
if (forNode === true) {
|
||||
return [60, 120, 240, 300, 360, 480, 600, 900, 1800, 3600, 7200, 14400, 21600]; // forNode fixed increments
|
||||
@ -118,7 +102,7 @@
|
||||
|
||||
<script>
|
||||
import uPlot from "uplot";
|
||||
import { formatNumber } from "../units.js";
|
||||
import { formatNumber, formatTime } from "../units.js";
|
||||
import { getContext, onMount, onDestroy, createEventDispatcher } from "svelte";
|
||||
import { Card } from "@sveltestrap/sveltestrap";
|
||||
|
||||
|
@ -55,7 +55,7 @@
|
||||
const getValues = (type) => labels.map(name => {
|
||||
// Peak is adapted and scaled for job shared state
|
||||
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
|
||||
return value <= 1. ? value : 1.
|
||||
})
|
||||
|
@ -17,6 +17,10 @@ export function formatNumber(x) {
|
||||
}
|
||||
}
|
||||
|
||||
export function roundTwoDigits(x) {
|
||||
return Math.round(x * 100) / 100
|
||||
}
|
||||
|
||||
export function scaleNumbers(x, y , p = '') {
|
||||
const oldPower = power[prefix.indexOf(p)]
|
||||
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()}`
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -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
|
||||
if (secondsToHours) {
|
||||
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)
|
||||
} else if (secondsToMinutes) {
|
||||
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)
|
||||
} else {
|
||||
uplotData[0].push(cd.value)
|
||||
|
@ -42,7 +42,7 @@
|
||||
query ($dbid: ID!, $selectedMetrics: [String!]!) {
|
||||
jobStats(id: $dbid, metrics: $selectedMetrics) {
|
||||
name
|
||||
stats {
|
||||
data {
|
||||
min
|
||||
avg
|
||||
max
|
||||
|
Loading…
x
Reference in New Issue
Block a user