Merge latest state branch 'dev' into migrate_svelte5

This commit is contained in:
Christoph Kluge
2025-05-13 18:25:54 +02:00
92 changed files with 6082 additions and 3223 deletions

View File

@@ -26,6 +26,8 @@
init,
convert2uplot,
binsFromFootprint,
scramble,
scrambleNames,
} from "./generic/utils.js";
import PlotSelection from "./analysis/PlotSelection.svelte";
import Filters from "./generic/Filters.svelte";
@@ -396,7 +398,7 @@
quantities={$topQuery.data.topList.map(
(t) => t[sortSelection.key],
)}
entities={$topQuery.data.topList.map((t) => t.id)}
entities={$topQuery.data.topList.map((t) => scrambleNames ? scramble(t.id) : t.id)}
/>
{/if}
{/key}
@@ -429,21 +431,21 @@
{#if groupSelection.key == "user"}
<th scope="col" id="topName-{te.id}"
><a href="/monitoring/user/{te.id}?cluster={clusterName}"
>{te.id}</a
>{scrambleNames ? scramble(te.id) : te.id}</a
></th
>
{#if te?.name}
<Tooltip
target={`topName-${te.id}`}
placement="left"
>{te.name}</Tooltip
>{scrambleNames ? scramble(te.name) : te.name}</Tooltip
>
{/if}
{:else}
<th scope="col"
><a
href="/monitoring/jobs/?cluster={clusterName}&project={te.id}&projectMatch=eq"
>{te.id}</a
>{scrambleNames ? scramble(te.id) : te.id}</a
></th
>
{/if}

View File

@@ -40,7 +40,7 @@
import JobRoofline from "./job/JobRoofline.svelte";
import EnergySummary from "./job/EnergySummary.svelte";
import PlotGrid from "./generic/PlotGrid.svelte";
import StatsTable from "./job/StatsTable.svelte";
import StatsTab from "./job/StatsTab.svelte";
export let dbid;
export let username;
@@ -53,10 +53,8 @@
let isMetricsSelectionOpen = false,
selectedMetrics = [],
selectedScopes = [];
let plots = {},
statsTable
selectedScopes = [],
plots = {};
let availableMetrics = new Set(),
missingMetrics = [],
@@ -127,19 +125,16 @@
let job = $initq.data.job;
if (!job) return;
const pendingMetrics = [
...(ccconfig[`job_view_selectedMetrics:${job.cluster}`] ||
$initq.data.globalMetrics.reduce((names, gm) => {
if (gm.availability.find((av) => av.cluster === job.cluster)) {
names.push(gm.name);
}
return names;
}, [])
),
...(ccconfig[`job_view_nodestats_selectedMetrics:${job.cluster}`] ||
ccconfig[`job_view_nodestats_selectedMetrics`]
),
];
const pendingMetrics = (
ccconfig[`job_view_selectedMetrics:${job.cluster}:${job.subCluster}`] ||
ccconfig[`job_view_selectedMetrics:${job.cluster}`]
) ||
$initq.data.globalMetrics.reduce((names, gm) => {
if (gm.availability.find((av) => av.cluster === job.cluster && av.subClusters.includes(job.subCluster))) {
names.push(gm.name);
}
return names;
}, [])
// Select default Scopes to load: Check before if any metric has accelerator scope by default
const accScopeDefault = [...pendingMetrics].some(function (m) {
@@ -222,7 +217,7 @@
<Col xs={12} md={6} xl={3} class="mb-3 mb-xxl-0">
{#if $initq.error}
<Card body color="danger">{$initq.error.message}</Card>
{:else if $initq.data}
{:else if $initq?.data}
<Card class="overflow-auto" style="height: 400px;">
<TabContent> <!-- on:tab={(e) => (status = e.detail)} -->
{#if $initq.data?.job?.metaData?.message}
@@ -296,7 +291,7 @@
<Card class="mb-3">
<CardBody>
<Row class="mb-2">
{#if $initq.data}
{#if $initq?.data}
<Col xs="auto">
<Button outline on:click={() => (isMetricsSelectionOpen = true)} color="primary">
Select Metrics (Selected {selectedMetrics.length} of {availableMetrics.size} available)
@@ -309,7 +304,7 @@
{#if $jobMetrics.error}
<Row class="mt-2">
<Col>
{#if $initq.data.job.monitoringStatus == 0 || $initq.data.job.monitoringStatus == 2}
{#if $initq?.data && ($initq.data.job?.monitoringStatus == 0 || $initq.data.job?.monitoringStatus == 2)}
<Card body color="warning">Not monitored or archiving failed</Card>
<br />
{/if}
@@ -334,7 +329,6 @@
{#if item.data}
<Metric
bind:this={plots[item.metric]}
on:more-loaded={({ detail }) => statsTable.moreLoaded(detail)}
job={$initq.data.job}
metricName={item.metric}
metricUnit={$initq.data.globalMetrics.find((gm) => gm.name == item.metric)?.unit}
@@ -343,10 +337,25 @@
scopes={item.data.map((x) => x.scope)}
isShared={$initq.data.job.exclusive != 1}
/>
{:else if item.disabled == true}
<Card color="info">
<CardHeader class="mb-0">
<b>Disabled Metric</b>
</CardHeader>
<CardBody>
<p>Metric <b>{item.metric}</b> is disabled for subcluster <b>{$initq.data.job.subCluster}</b>.</p>
<p class="mb-1">To remove this card, open metric selection and press "Close and Apply".</p>
</CardBody>
</Card>
{:else}
<Card body color="warning" class="mt-2"
>No dataset returned for <code>{item.metric}</code></Card
>
<Card color="warning" class="mt-2">
<CardHeader class="mb-0">
<b>Missing Metric</b>
</CardHeader>
<CardBody>
<p class="mb-1">No dataset returned for <b>{item.metric}</b>.</p>
</CardBody>
</Card>
{/if}
</PlotGrid>
{/if}
@@ -356,7 +365,7 @@
<!-- Statistcics Table -->
<Row class="mb-3">
<Col>
{#if $initq.data}
{#if $initq?.data}
<Card>
<TabContent>
{#if somethingMissing}
@@ -389,22 +398,8 @@
</div>
</TabPane>
{/if}
<TabPane
tabId="stats"
tab="Statistics Table"
class="overflow-x-auto"
active={!somethingMissing}
>
{#if $jobMetrics?.data?.jobMetrics}
{#key $jobMetrics.data.jobMetrics}
<StatsTable
bind:this={statsTable}
job={$initq.data.job}
jobMetrics={$jobMetrics.data.jobMetrics}
/>
{/key}
{/if}
</TabPane>
<!-- Includes <TabPane> Statistics Table with Independent GQL Query -->
<StatsTab job={$initq.data.job} clusters={$initq.data.clusters} tabActive={!somethingMissing}/>
<TabPane tabId="job-script" tab="Job Script">
<div class="pre-wrapper">
{#if $initq.data.job.metaData?.jobScript}
@@ -431,9 +426,10 @@
</Col>
</Row>
{#if $initq.data}
{#if $initq?.data}
<MetricSelection
cluster={$initq.data.job.cluster}
subCluster={$initq.data.job.subCluster}
configName="job_view_selectedMetrics"
bind:metrics={selectedMetrics}
bind:isOpen={isMetricsSelectionOpen}

View File

@@ -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;
jobList.queryJobs(detail.filters);
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">
<TextFilter
{presetProject}
bind:authlevel
bind:roles
on:set-filter={({ detail }) => filterComponent.updateFilters(detail)}
/>
<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">
<Refresher on:refresh={() => {
jobList.refreshJobs()
jobList.refreshAllMetrics()
}} />
<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>
<JobList
bind:this={jobList}
bind:metrics
bind:sorting
bind:matchedJobs
bind:showFootprint
/>
{#if !showCompare}
<JobList
bind:this={jobList}
bind:metrics
bind:sorting
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}
@@ -137,5 +186,5 @@
bind:metrics
bind:isOpen={isMetricsSelectionOpen}
bind:showFootprint
footprintSelect={true}
footprintSelect
/>

View File

@@ -31,6 +31,8 @@
init,
convert2uplot,
transformPerNodeDataForRoofline,
scramble,
scrambleNames,
} from "./generic/utils.js";
import { scaleNumbers } from "./generic/units.js";
import PlotGrid from "./generic/PlotGrid.svelte";
@@ -487,7 +489,7 @@
quantities={$topUserQuery.data.topUser.map(
(tu) => tu[topUserSelection.key],
)}
entities={$topUserQuery.data.topUser.map((tu) => tu.id)}
entities={$topUserQuery.data.topUser.map((tu) => scrambleNames ? scramble(tu.id) : tu.id)}
/>
{/if}
{/key}
@@ -521,14 +523,14 @@
<th scope="col" id="topName-{tu.id}"
><a
href="/monitoring/user/{tu.id}?cluster={cluster}&state=running"
>{tu.id}</a
>{scrambleNames ? scramble(tu.id) : tu.id}</a
></th
>
{#if tu?.name}
<Tooltip
target={`topName-${tu.id}`}
placement="left"
>{tu.name}</Tooltip
>{scrambleNames ? scramble(tu.name) : tu.name}</Tooltip
>
{/if}
<td>{tu[topUserSelection.key]}</td>
@@ -555,7 +557,7 @@
quantities={$topProjectQuery.data.topProjects.map(
(tp) => tp[topProjectSelection.key],
)}
entities={$topProjectQuery.data.topProjects.map((tp) => tp.id)}
entities={$topProjectQuery.data.topProjects.map((tp) => scrambleNames ? scramble(tp.id) : tp.id)}
/>
{/if}
{/key}
@@ -588,7 +590,7 @@
<th scope="col"
><a
href="/monitoring/jobs/?cluster={cluster}&state=running&project={tp.id}&projectMatch=eq"
>{tp.id}</a
>{scrambleNames ? scramble(tp.id) : tp.id}</a
></th
>
<td>{tp[topProjectSelection.key]}</td>

View File

@@ -29,8 +29,8 @@
import Refresher from "./generic/helper/Refresher.svelte";
export let displayType;
export let cluster;
export let subCluster = "";
export let cluster = null;
export let subCluster = null;
export let from = null;
export let to = null;
@@ -60,7 +60,10 @@
let hostnameFilter = "";
let pendingHostnameFilter = "";
let selectedMetric = ccconfig.system_view_selectedMetric || "";
let selectedMetrics = ccconfig[`node_list_selectedMetrics:${cluster}`] || [ccconfig.system_view_selectedMetric];
let selectedMetrics = (
ccconfig[`node_list_selectedMetrics:${cluster}:${subCluster}`] ||
ccconfig[`node_list_selectedMetrics:${cluster}`]
) || [ccconfig.system_view_selectedMetric];
let isMetricsSelectionOpen = false;
/*
@@ -191,6 +194,7 @@
<MetricSelection
{cluster}
{subCluster}
configName="node_list_selectedMetrics"
metrics={selectedMetrics}
bind:isOpen={isMetricsSelectionOpen}

View File

@@ -0,0 +1,110 @@
<!--
@component Tag List Svelte Component. Displays All Tags, Allows deletion.
Properties:
- `username String!`: Users username.
- `isAdmin Bool!`: User has Admin Auth.
- `tagmap Object!`: Map of accessible, appwide tags. Prefiltered in backend.
-->
<script>
import {
gql,
getContextClient,
mutationStore,
} from "@urql/svelte";
import {
Badge,
InputGroup,
Icon,
Button,
Spinner,
} from "@sveltestrap/sveltestrap";
import {
init,
} from "./generic/utils.js";
export let username;
export let isAdmin;
export let tagmap;
const {} = init();
const client = getContextClient();
let pendingChange = "none";
const removeTagMutation = ({ tagIds }) => {
return mutationStore({
client: client,
query: gql`
mutation ($tagIds: [ID!]!) {
removeTagFromList(tagIds: $tagIds)
}
`,
variables: { tagIds },
});
};
function removeTag(tag, tagType) {
if (confirm("Are you sure you want to completely remove this tag?\n\n" + tagType + ':' + tag.name)) {
pendingChange = tagType;
removeTagMutation({tagIds: [tag.id] }).subscribe(
(res) => {
if (res.fetching === false && !res.error) {
tagmap[tagType] = tagmap[tagType].filter((t) => !res.data.removeTagFromList.includes(t.id));
if (tagmap[tagType].length === 0) {
delete tagmap[tagType]
}
pendingChange = "none";
} else if (res.fetching === false && res.error) {
throw res.error;
}
},
);
}
}
</script>
<div class="container">
<div class="row justify-content-center">
<div class="col-10">
{#each Object.entries(tagmap) as [tagType, tagList]}
<div class="my-3 p-2 bg-secondary rounded text-white"> <!-- text-capitalize -->
Tag Type: <b>{tagType}</b>
{#if pendingChange === tagType}
<Spinner size="sm" secondary />
{/if}
<span style="float: right; padding-bottom: 0.4rem; padding-top: 0.4rem;" class="badge bg-light text-secondary">
{tagList.length} Tag{(tagList.length != 1)?'s':''}
</span>
</div>
<div class="d-inline-flex flex-wrap">
{#each tagList as tag (tag.id)}
<InputGroup class="w-auto flex-nowrap" style="margin-right: 0.5rem; margin-bottom: 0.5rem;">
<Button outline color="secondary" href="/monitoring/jobs/?tag={tag.id}" target="_blank">
<Badge color="light" style="font-size:medium;" border>{tag.name}</Badge> :
<Badge color="primary" pill>{tag.count} Job{(tag.count != 1)?'s':''}</Badge>
{#if tag.scope == "global"}
<Badge style="background-color:#c85fc8 !important;" pill>Global</Badge>
{:else if tag.scope == "admin"}
<Badge style="background-color:#19e5e6 !important;" pill>Admin</Badge>
{:else}
<Badge color="warning" pill>Private</Badge>
{/if}
</Button>
{#if (isAdmin && (tag.scope == "admin" || tag.scope == "global")) || tag.scope == username }
<Button
size="sm"
color="danger"
on:click={() => removeTag(tag, tagType)}
>
<Icon name="x" />
</Button>
{/if}
</InputGroup>
{/each}
</div>
{/each}
</div>
</div>
</div>

View File

@@ -352,7 +352,7 @@
bind:metrics
bind:isOpen={isMetricsSelectionOpen}
bind:showFootprint
footprintSelect={true}
footprintSelect
/>
<HistogramSelection

View File

@@ -44,11 +44,59 @@
export let disableClusterSelection = false;
export let startTimeQuickSelect = false;
export let matchedJobs = -2;
export let showFilter = true;
const startTimeSelectOptions = [
{ range: "", rangeLabel: "No Selection"},
{ range: "last6h", rangeLabel: "Last 6hrs"},
{ range: "last24h", rangeLabel: "Last 24hrs"},
{ range: "last7d", rangeLabel: "Last 7 days"},
{ range: "last30d", rangeLabel: "Last 30 days"}
];
const nodeMatchLabels = {
eq: "",
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",
jobIdMatch: filterPresets.jobIdMatch || "eq",
nodeMatch: filterPresets.nodeMatch || "eq",
cluster: filterPresets.cluster || null,
partition: filterPresets.partition || null,
@@ -56,7 +104,7 @@
filterPresets.states || filterPresets.state
? [filterPresets.state].flat()
: allJobStates,
startTime: filterPresets.startTime || { from: null, to: null },
startTime: filterPresets.startTime || { from: null, to: null, range: ""},
tags: filterPresets.tags || [],
duration: filterPresets.duration || {
lessThan: null,
@@ -64,6 +112,7 @@
from: null,
to: null,
},
dbId: filterPresets.dbId || [],
jobId: filterPresets.jobId || "",
arrayJobId: filterPresets.arrayJobId || null,
user: filterPresets.user || "",
@@ -92,13 +141,20 @@
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: { contains: filters.node } });
if (filters.node) items.push({ node: { [filters.nodeMatch]: filters.node } });
if (filters.partition) items.push({ partition: { eq: filters.partition } });
if (filters.states.length != allJobStates.length)
items.push({ state: filters.states });
@@ -123,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)
@@ -166,10 +224,12 @@
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
opts.push(`nodeMatch=${filters.nodeMatch}`);
if (filters.partition) opts.push(`partition=${filters.partition}`);
if (filters.states.length != allJobStates.length)
for (let state of filters.states) opts.push(`state=${state}`);
@@ -180,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}`);
@@ -188,7 +253,7 @@
opts.push(`jobId=${singleJobId}`);
}
if (filters.jobIdMatch != "eq")
opts.push(`jobIdMatch=${filters.jobIdMatch}`);
opts.push(`jobIdMatch=${filters.jobIdMatch}`); // "eq" is default-case
for (let tag of filters.tags) opts.push(`tag=${tag}`);
if (filters.duration.from && filters.duration.to)
opts.push(`duration=${filters.duration.from}-${filters.duration.to}`);
@@ -210,19 +275,19 @@
} else {
for (let singleUser of filters.user) opts.push(`user=${singleUser}`);
}
if (filters.userMatch != "contains")
if (filters.userMatch != "contains") // "contains" is default-case
opts.push(`userMatch=${filters.userMatch}`);
if (filters.project) opts.push(`project=${filters.project}`);
if (filters.project && filters.projectMatch != "contains") // "contains" is default-case
opts.push(`projectMatch=${filters.projectMatch}`);
if (filters.jobName) opts.push(`jobName=${filters.jobName}`);
if (filters.arrayJobId) opts.push(`arrayJobId=${filters.arrayJobId}`);
if (filters.project && filters.projectMatch != "contains")
opts.push(`projectMatch=${filters.projectMatch}`);
if (filters.stats.length != 0)
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;
let newurl = `${window.location.pathname}?${opts.join("&")}`;
window.history.replaceState(null, "", newurl);
}
@@ -230,59 +295,63 @@
<!-- Dropdown-Button -->
<ButtonGroup>
<ButtonDropdown class="cc-dropdown-on-hover mb-1" style="{(matchedJobs >= -1) ? '' : 'margin-right: 0.5rem;'}">
<DropdownToggle outline caret color="success">
<Icon name="sliders" />
Filters
</DropdownToggle>
<DropdownMenu>
<DropdownItem header>Manage Filters</DropdownItem>
{#if menuText}
<DropdownItem disabled>{menuText}</DropdownItem>
<DropdownItem divider />
{/if}
<DropdownItem on:click={() => (isClusterOpen = true)}>
<Icon name="cpu" /> Cluster/Partition
</DropdownItem>
<DropdownItem on:click={() => (isJobStatesOpen = true)}>
<Icon name="gear-fill" /> Job States
</DropdownItem>
<DropdownItem on:click={() => (isStartTimeOpen = true)}>
<Icon name="calendar-range" /> Start Time
</DropdownItem>
<DropdownItem on:click={() => (isDurationOpen = true)}>
<Icon name="stopwatch" /> Duration
</DropdownItem>
<DropdownItem on:click={() => (isTagsOpen = true)}>
<Icon name="tags" /> Tags
</DropdownItem>
<DropdownItem on:click={() => (isResourcesOpen = true)}>
<Icon name="hdd-stack" /> Resources
</DropdownItem>
<DropdownItem on:click={() => (isEnergyOpen = true)}>
<Icon name="lightning-charge-fill" /> Energy
</DropdownItem>
<DropdownItem on:click={() => (isStatsOpen = true)}>
<Icon name="bar-chart" on:click={() => (isStatsOpen = true)} /> Statistics
</DropdownItem>
{#if startTimeQuickSelect}
<DropdownItem divider />
<DropdownItem disabled>Start Time Quick Selection</DropdownItem>
{#each [{ text: "Last 6hrs", range: "last6h" }, { text: "Last 24hrs", range: "last24h" }, { text: "Last 7 days", range: "last7d" }, { text: "Last 30 days", range: "last30d" }] as { text, range }}
<DropdownItem
on:click={() => {
filters.startTime.range = range;
filters.startTime.text = text;
updateFilters();
}}
>
<Icon name="calendar-range" />
{text}
</DropdownItem>
{/each}
{/if}
</DropdownMenu>
</ButtonDropdown>
{#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" />
Filters
</DropdownToggle>
<DropdownMenu>
<DropdownItem header>Manage Filters</DropdownItem>
{#if menuText}
<DropdownItem disabled>{menuText}</DropdownItem>
<DropdownItem divider />
{/if}
<DropdownItem on:click={() => (isClusterOpen = true)}>
<Icon name="cpu" /> Cluster/Partition
</DropdownItem>
<DropdownItem on:click={() => (isJobStatesOpen = true)}>
<Icon name="gear-fill" /> Job States
</DropdownItem>
<DropdownItem on:click={() => (isStartTimeOpen = true)}>
<Icon name="calendar-range" /> Start Time
</DropdownItem>
<DropdownItem on:click={() => (isDurationOpen = true)}>
<Icon name="stopwatch" /> Duration
</DropdownItem>
<DropdownItem on:click={() => (isTagsOpen = true)}>
<Icon name="tags" /> Tags
</DropdownItem>
<DropdownItem on:click={() => (isResourcesOpen = true)}>
<Icon name="hdd-stack" /> Resources
</DropdownItem>
<DropdownItem on:click={() => (isEnergyOpen = true)}>
<Icon name="lightning-charge-fill" /> Energy
</DropdownItem>
<DropdownItem on:click={() => (isStatsOpen = true)}>
<Icon name="bar-chart" on:click={() => (isStatsOpen = true)} /> Statistics
</DropdownItem>
{#if startTimeQuickSelect}
<DropdownItem divider />
<DropdownItem disabled>Start Time Quick Selection</DropdownItem>
{#each startTimeSelectOptions.filter((stso) => stso.range !== "") as { rangeLabel, range }}
<DropdownItem
on:click={() => {
filters.startTime.from = null
filters.startTime.to = null
filters.startTime.range = range;
updateFilters();
}}
>
<Icon name="calendar-range" />
{rangeLabel}
</DropdownItem>
{/each}
{/if}
</DropdownMenu>
</ButtonDropdown>
{/if}
{#if matchedJobs >= -1}
<Button class="mb-1" style="margin-right: 0.5rem;" disabled outline>
{matchedJobs == -1 ? 'Loading ...' : `${matchedJobs} jobs`}
@@ -290,109 +359,111 @@
{/if}
</ButtonGroup>
<!-- SELECTED FILTER PILLS -->
{#if filters.cluster}
<Info icon="cpu" on:click={() => (isClusterOpen = true)}>
{filters.cluster}
{#if filters.partition}
({filters.partition})
{/if}
</Info>
{/if}
{#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 filters.states.length != allJobStates.length}
<Info icon="gear-fill" on:click={() => (isJobStatesOpen = true)}>
{filters.states.join(", ")}
</Info>
{/if}
{#if filters.states.length != allJobStates.length}
<Info icon="gear-fill" on:click={() => (isJobStatesOpen = true)}>
{filters.states.join(", ")}
</Info>
{/if}
{#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 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 filters.startTime.range}
<Info icon="calendar-range" on:click={() => (isStartTimeOpen = true)}>
{filters?.startTime?.text ? filters.startTime.text : filters.startTime.range }
</Info>
{/if}
{#if filters.startTime.range}
<Info icon="calendar-range" on:click={() => (isStartTimeOpen = true)}>
{startTimeSelectOptions.find((stso) => stso.range === filters.startTime.range).rangeLabel }
</Info>
{/if}
{#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,
)}m -
{Math.floor(filters.duration.to / 3600)}h:{Math.floor(
(filters.duration.to % 3600) / 60,
)}m
</Info>
{/if}
{#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,
)}m -
{Math.floor(filters.duration.to / 3600)}h:{Math.floor(
(filters.duration.to % 3600) / 60,
)}m
</Info>
{/if}
{#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 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 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 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 filters.tags.length != 0}
<Info icon="tags" on:click={() => (isTagsOpen = true)}>
{#each filters.tags as tagId}
{#key tagId}
<Tag id={tagId} clickable={false} />
{/key}
{/each}
</Info>
{/if}
{#if filters.tags.length != 0}
<Info icon="tags" on:click={() => (isTagsOpen = true)}>
{#each filters.tags as tagId}
{#key tagId}
<Tag id={tagId} clickable={false} />
{/key}
{/each}
</Info>
{/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}
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
{#if isNodesModified}
Nodes: {filters.numNodes.from} - {filters.numNodes.to}
{/if}
{#if isNodesModified && isHwthreadsModified},
{/if}
{#if isHwthreadsModified}
HWThreads: {filters.numHWThreads.from} - {filters.numHWThreads.to}
{/if}
{#if (isNodesModified || isHwthreadsModified) && isAccsModified},
{/if}
{#if isAccsModified}
Accelerators: {filters.numAccelerators.from} - {filters.numAccelerators.to}
{/if}
</Info>
{/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}
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
{#if isNodesModified}
Nodes: {filters.numNodes.from} - {filters.numNodes.to}
{/if}
{#if isNodesModified && isHwthreadsModified},
{/if}
{#if isHwthreadsModified}
HWThreads: {filters.numHWThreads.from} - {filters.numHWThreads.to}
{/if}
{#if (isNodesModified || isHwthreadsModified) && isAccsModified},
{/if}
{#if isAccsModified}
Accelerators: {filters.numAccelerators.from} - {filters.numAccelerators.to}
{/if}
</Info>
{/if}
{#if filters.node != null}
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
Node: {filters.node}
</Info>
{/if}
{#if filters.node != null}
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
Node{nodeMatchLabels[filters.nodeMatch]}: {filters.node}
</Info>
{/if}
{#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 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 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 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
@@ -414,11 +485,8 @@
bind:from={filters.startTime.from}
bind:to={filters.startTime.to}
bind:range={filters.startTime.range}
on:set-filter={() => {
delete filters.startTime["text"];
delete filters.startTime["range"];
updateFilters();
}}
{startTimeSelectOptions}
on:set-filter={() => updateFilters()}
/>
<Duration
@@ -443,6 +511,7 @@
bind:numHWThreads={filters.numHWThreads}
bind:numAccelerators={filters.numAccelerators}
bind:namedNode={filters.node}
bind:nodeMatch={filters.nodeMatch}
bind:isNodesModified
bind:isHwthreadsModified
bind:isAccsModified

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 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);

View File

@@ -24,6 +24,7 @@
ModalBody,
ModalHeader,
ModalFooter,
Input
} from "@sveltestrap/sveltestrap";
import DoubleRangeSlider from "../select/DoubleRangeSlider.svelte";
@@ -40,11 +41,18 @@
export let isHwthreadsModified = false;
export let isAccsModified = false;
export let namedNode = null;
export let nodeMatch = "eq"
let pendingNumNodes = numNodes,
pendingNumHWThreads = numHWThreads,
pendingNumAccelerators = numAccelerators,
pendingNamedNode = namedNode;
pendingNamedNode = namedNode,
pendingNodeMatch = nodeMatch;
const nodeMatchLabels = {
eq: "Equal To",
contains: "Contains",
}
const findMaxNumAccels = (clusters) =>
clusters.reduce(
@@ -145,7 +153,17 @@
<ModalHeader>Select number of utilized Resources</ModalHeader>
<ModalBody>
<h6>Named Node</h6>
<input type="text" class="form-control" bind:value={pendingNamedNode} />
<div class="d-flex">
<Input type="text" class="w-75" bind:value={pendingNamedNode} />
<div class="mx-1"></div>
<Input type="select" class="w-25" bind:value={pendingNodeMatch}>
{#each Object.entries(nodeMatchLabels) as [nodeMatchKey, nodeMatchLabel]}
<option value={nodeMatchKey}>
{nodeMatchLabel}
</option>
{/each}
</Input>
</div>
<h6 style="margin-top: 1rem;">Number of Nodes</h6>
<DoubleRangeSlider
on:change={({ detail }) => {
@@ -215,11 +233,13 @@
to: pendingNumAccelerators.to,
};
namedNode = pendingNamedNode;
nodeMatch = pendingNodeMatch;
dispatch("set-filter", {
numNodes,
numHWThreads,
numAccelerators,
namedNode,
nodeMatch
});
}}
>
@@ -233,6 +253,7 @@
pendingNumHWThreads = { from: null, to: null };
pendingNumAccelerators = { from: null, to: null };
pendingNamedNode = null;
pendingNodeMatch = null;
numNodes = { from: pendingNumNodes.from, to: pendingNumNodes.to };
numHWThreads = {
from: pendingNumHWThreads.from,
@@ -246,11 +267,13 @@
isHwthreadsModified = false;
isAccsModified = false;
namedNode = pendingNamedNode;
nodeMatch = pendingNodeMatch;
dispatch("set-filter", {
numNodes,
numHWThreads,
numAccelerators,
namedNode,
nodeMatch
});
}}>Reset</Button
>

View File

@@ -17,7 +17,6 @@
import { parse, format, sub } from "date-fns";
import {
Row,
Col,
Button,
Input,
Modal,
@@ -34,8 +33,7 @@
export let from = null;
export let to = null;
export let range = "";
let pendingFrom, pendingTo;
export let startTimeSelectOptions;
const now = new Date(Date.now());
const ago = sub(now, { months: 1 });
@@ -48,12 +46,24 @@
time: format(now, "HH:mm"),
};
function reset() {
pendingFrom = from == null ? defaultFrom : fromRFC3339(from);
pendingTo = to == null ? defaultTo : fromRFC3339(to);
}
$: pendingFrom = (from == null) ? defaultFrom : fromRFC3339(from)
$: pendingTo = (to == null) ? defaultTo : fromRFC3339(to)
$: pendingRange = range
reset();
$: isModified =
(from != toRFC3339(pendingFrom) || to != toRFC3339(pendingTo, "59")) &&
(range != pendingRange) &&
!(
from == null &&
pendingFrom.date == "0000-00-00" &&
pendingFrom.time == "00:00"
) &&
!(
to == null &&
pendingTo.date == "0000-00-00" &&
pendingTo.time == "00:00"
) &&
!( range == "" && pendingRange == "");
function toRFC3339({ date, time }, secs = "00") {
const parsedDate = parse(
@@ -71,19 +81,6 @@
time: format(parsedDate, "HH:mm"),
};
}
$: isModified =
(from != toRFC3339(pendingFrom) || to != toRFC3339(pendingTo, "59")) &&
!(
from == null &&
pendingFrom.date == "0000-00-00" &&
pendingFrom.time == "00:00"
) &&
!(
to == null &&
pendingTo.date == "0000-00-00" &&
pendingTo.time == "00:00"
);
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
@@ -92,52 +89,82 @@
{#if range !== ""}
<h4>Current Range</h4>
<Row>
<Col>
<Input type="text" value={range} disabled/>
</Col>
<FormGroup class="col">
<Input type ="select" bind:value={pendingRange} >
{#each startTimeSelectOptions as { rangeLabel, range }}
<option label={rangeLabel} value={range}/>
{/each}
</Input>
</FormGroup>
</Row>
{/if}
<h4>From</h4>
<Row>
<FormGroup class="col">
<Input type="date" bind:value={pendingFrom.date} />
<Input type="date" bind:value={pendingFrom.date} disabled={pendingRange !== ""}/>
</FormGroup>
<FormGroup class="col">
<Input type="time" bind:value={pendingFrom.time} />
<Input type="time" bind:value={pendingFrom.time} disabled={pendingRange !== ""}/>
</FormGroup>
</Row>
<h4>To</h4>
<Row>
<FormGroup class="col">
<Input type="date" bind:value={pendingTo.date} />
<Input type="date" bind:value={pendingTo.date} disabled={pendingRange !== ""}/>
</FormGroup>
<FormGroup class="col">
<Input type="time" bind:value={pendingTo.time} />
<Input type="time" bind:value={pendingTo.time} disabled={pendingRange !== ""}/>
</FormGroup>
</Row>
</ModalBody>
<ModalFooter>
<Button
color="primary"
disabled={pendingFrom.date == "0000-00-00" ||
pendingTo.date == "0000-00-00"}
on:click={() => {
isOpen = false;
from = toRFC3339(pendingFrom);
to = toRFC3339(pendingTo, "59");
dispatch("set-filter", { from, to });
}}
>
Close & Apply
</Button>
{#if pendingRange !== ""}
<Button
color="warning"
disabled={pendingRange === ""}
on:click={() => {
pendingRange = ""
}}
>
Reset Range
</Button>
<Button
color="primary"
disabled={pendingRange === ""}
on:click={() => {
isOpen = false;
from = null;
to = null;
range = pendingRange;
dispatch("set-filter", { from, to, range });
}}
>
Close & Apply Range
</Button>
{:else}
<Button
color="primary"
disabled={pendingFrom.date == "0000-00-00" ||
pendingTo.date == "0000-00-00"}
on:click={() => {
isOpen = false;
from = toRFC3339(pendingFrom);
to = toRFC3339(pendingTo, "59");
range = "";
dispatch("set-filter", { from, to, range });
}}
>
Close & Apply Dates
</Button>
{/if}
<Button
color="danger"
on:click={() => {
isOpen = false;
from = null;
to = null;
reset();
dispatch("set-filter", { from, to });
range = "";
dispatch("set-filter", { from, to, range });
}}>Reset</Button
>
<Button on:click={() => (isOpen = false)}>Close</Button>

View File

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

View File

@@ -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;
@@ -38,6 +40,8 @@
let selectedResolution = resampleDefault;
let zoomStates = {};
let thresholdStates = {};
$: isSelected = previousSelect || null;
const cluster = getContext("clusters").find((c) => c.name == job.cluster);
const client = getContextClient();
@@ -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}>

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>
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;

View File

@@ -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";

View File

@@ -58,7 +58,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.
})

View File

@@ -28,6 +28,7 @@
export let configName;
export let allMetrics = null;
export let cluster = null;
export let subCluster = null;
export let showFootprint = false;
export let footprintSelect = false;
@@ -44,25 +45,29 @@
for (let metric of globalMetrics) allMetrics.add(metric.name);
});
$: if (newMetricsOrder.length === 0) {
if (allMetrics != null) {
if (cluster == null) {
for (let metric of globalMetrics) allMetrics.add(metric.name);
} else {
allMetrics.clear();
for (let gm of globalMetrics) {
$: {
if (allMetrics != null) {
if (!cluster) {
for (let metric of globalMetrics) allMetrics.add(metric.name);
} else {
allMetrics.clear();
for (let gm of globalMetrics) {
if (!subCluster) {
if (gm.availability.find((av) => av.cluster === cluster)) allMetrics.add(gm.name);
} else {
if (gm.availability.find((av) => av.cluster === cluster && av.subClusters.includes(subCluster))) allMetrics.add(gm.name);
}
}
newMetricsOrder = [...allMetrics].filter((m) => !metrics.includes(m));
newMetricsOrder.unshift(...metrics.filter((m) => allMetrics.has(m)));
unorderedMetrics = unorderedMetrics.filter((m) => allMetrics.has(m));
}
newMetricsOrder = [...allMetrics].filter((m) => !metrics.includes(m));
newMetricsOrder.unshift(...metrics.filter((m) => allMetrics.has(m)));
unorderedMetrics = unorderedMetrics.filter((m) => allMetrics.has(m));
}
}
function printAvailability(metric, cluster) {
const avail = globalMetrics.find((gm) => gm.name === metric)?.availability
if (cluster == null) {
if (!cluster) {
return avail.map((av) => av.cluster).join(',')
} else {
return avail.find((av) => av.cluster === cluster).subClusters.join(',')
@@ -110,10 +115,17 @@
metrics = newMetricsOrder.filter((m) => unorderedMetrics.includes(m));
isOpen = false;
showFootprint = !!pendingShowFootprint;
let configKey;
if (cluster && subCluster) {
configKey = `${configName}:${cluster}:${subCluster}`;
} else if (cluster && !subCluster) {
configKey = `${configName}:${cluster}`;
} else {
configKey = `${configName}`;
}
updateConfigurationMutation({
name: cluster == null ? configName : `${configName}:${cluster}`,
name: configKey,
value: JSON.stringify(metrics),
}).subscribe((res) => {
if (res.fetching === false && res.error) {
@@ -121,17 +133,20 @@
}
});
updateConfigurationMutation({
name:
cluster == null
? "plot_list_showFootprint"
: `plot_list_showFootprint:${cluster}`,
value: JSON.stringify(showFootprint),
}).subscribe((res) => {
if (res.fetching === false && res.error) {
throw res.error;
}
});
if (footprintSelect) {
showFootprint = !!pendingShowFootprint;
updateConfigurationMutation({
name:
!cluster
? "plot_list_showFootprint"
: `plot_list_showFootprint:${cluster}`,
value: JSON.stringify(showFootprint),
}).subscribe((res) => {
if (res.fetching === false && res.error) {
throw res.error;
}
});
};
dispatch('update-metrics', metrics);
}

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 = '') {
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);

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
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)

View File

@@ -14,7 +14,6 @@
<script>
import {
getContext,
createEventDispatcher
} from "svelte";
import {
queryStore,
@@ -56,7 +55,6 @@
let pendingZoomState = null;
let thresholdState = null;
const dispatch = createEventDispatcher();
const statsPattern = /(.*)-stat$/;
const unit = (metricUnit?.prefix ? metricUnit.prefix : "") + (metricUnit?.base ? metricUnit.base : "");
const client = getContextClient();
@@ -150,11 +148,6 @@
// On additional scope request
if (selectedScope == "load-all") {
// Push scope to statsTable (Needs to be in this case, else newly selected 'Metric.svelte' renders cause statsTable race condition)
const statsTableData = $metricData.data.singleUpdate.filter((x) => x.scope !== "node")
if (statsTableData.length > 0) {
dispatch("more-loaded", statsTableData);
}
// Set selected scope to min of returned scopes
selectedScope = minScope(scopes)
nodeOnly = (selectedScope == "node") // "node" still only scope after load-all

View File

@@ -0,0 +1,145 @@
<!--
@component Job-View subcomponent; Wraps the statsTable in a TabPane and contains GQL query for scoped statsData
Properties:
- `job Object`: The job object
- `clusters Object`: The clusters object
- `tabActive bool`: Boolean if StatsTabe Tab is Active on Creation
-->
<script>
import {
queryStore,
gql,
getContextClient
} from "@urql/svelte";
import { getContext } from "svelte";
import {
Card,
Button,
Row,
Col,
TabPane,
Spinner,
Icon
} from "@sveltestrap/sveltestrap";
import MetricSelection from "../generic/select/MetricSelection.svelte";
import StatsTable from "./statstab/StatsTable.svelte";
export let job;
export let clusters;
export let tabActive;
let loadScopes = false;
let selectedScopes = [];
let selectedMetrics = [];
let availableMetrics = new Set(); // For Info Only, filled by MetricSelection Component
let isMetricSelectionOpen = false;
const client = getContextClient();
const query = gql`
query ($dbid: ID!, $selectedMetrics: [String!]!, $selectedScopes: [MetricScope!]!) {
scopedJobStats(id: $dbid, metrics: $selectedMetrics, scopes: $selectedScopes) {
name
scope
stats {
hostname
id
data {
min
avg
max
}
}
}
}
`;
$: scopedStats = queryStore({
client: client,
query: query,
variables: { dbid: job.id, selectedMetrics, selectedScopes },
});
$: if (loadScopes) {
selectedScopes = ["node", "socket", "core", "hwthread", "accelerator"];
}
// Handle Job Query on Init -> is not executed anymore
getContext("on-init")(() => {
if (!job) return;
const pendingMetrics = (
getContext("cc-config")[`job_view_nodestats_selectedMetrics:${job.cluster}:${job.subCluster}`] ||
getContext("cc-config")[`job_view_nodestats_selectedMetrics:${job.cluster}`]
) || getContext("cc-config")["job_view_nodestats_selectedMetrics"];
// Select default Scopes to load: Check before if any metric has accelerator scope by default
const accScopeDefault = [...pendingMetrics].some(function (m) {
const cluster = clusters.find((c) => c.name == job.cluster);
const subCluster = cluster.subClusters.find((sc) => sc.name == job.subCluster);
return subCluster.metricConfig.find((smc) => smc.name == m)?.scope === "accelerator";
});
const pendingScopes = ["node"]
if (job.numNodes === 1) {
pendingScopes.push("socket")
pendingScopes.push("core")
pendingScopes.push("hwthread")
if (accScopeDefault) { pendingScopes.push("accelerator") }
}
selectedMetrics = [...pendingMetrics];
selectedScopes = [...pendingScopes];
});
</script>
<TabPane tabId="stats" tab="Statistics Table" class="overflow-x-auto" active={tabActive}>
<Row>
<Col class="m-2">
<Button outline on:click={() => (isMetricSelectionOpen = true)} class="px-2" color="primary" style="margin-right:0.5rem">
Select Metrics (Selected {selectedMetrics.length} of {availableMetrics.size} available)
</Button>
{#if job.numNodes > 1}
<Button class="px-2 ml-auto" color="success" outline on:click={() => (loadScopes = !loadScopes)} disabled={loadScopes}>
{#if !loadScopes}
<Icon name="plus-square-fill" style="margin-right:0.25rem"/> Add More Scopes
{:else}
<Icon name="check-square-fill" style="margin-right:0.25rem"/> OK: Scopes Added
{/if}
</Button>
{/if}
</Col>
</Row>
<hr class="mb-1 mt-1"/>
<!-- ROW1: Status-->
{#if $scopedStats.fetching}
<Row>
<Col class="m-3" style="text-align: center;">
<Spinner secondary/>
</Col>
</Row>
{:else if $scopedStats.error}
<Row>
<Col class="m-2">
<Card body color="danger">{$scopedStats.error.message}</Card>
</Col>
</Row>
{:else}
<StatsTable
hosts={job.resources.map((r) => r.hostname).sort()}
data={$scopedStats?.data?.scopedJobStats}
{selectedMetrics}
/>
{/if}
</TabPane>
<MetricSelection
cluster={job.cluster}
subCluster={job.subCluster}
configName="job_view_nodestats_selectedMetrics"
bind:allMetrics={availableMetrics}
bind:metrics={selectedMetrics}
bind:isOpen={isMetricSelectionOpen}
/>

View File

@@ -1,176 +0,0 @@
<!--
@component Job-View subcomponent; display table of metric data statistics with selectable scopes
Properties:
- `job Object`: The job object
- `jobMetrics [Object]`: The jobs metricdata
Exported:
- `moreLoaded`: Adds additional scopes requested from Metric.svelte in Job-View
-->
<script>
import { getContext } from "svelte";
import {
Button,
Table,
Input,
InputGroup,
InputGroupText,
Icon,
Row,
Col
} from "@sveltestrap/sveltestrap";
import { maxScope } from "../generic/utils.js";
import StatsTableEntry from "./StatsTableEntry.svelte";
import MetricSelection from "../generic/select/MetricSelection.svelte";
export let job;
export let jobMetrics;
const sortedJobMetrics = [...new Set(jobMetrics.map((m) => m.name))].sort()
const scopesForMetric = (metric) =>
jobMetrics.filter((jm) => jm.name == metric).map((jm) => jm.scope);
let hosts = job.resources.map((r) => r.hostname).sort(),
selectedScopes = {},
sorting = {},
isMetricSelectionOpen = false,
availableMetrics = new Set(),
selectedMetrics =
getContext("cc-config")[`job_view_nodestats_selectedMetrics:${job.cluster}`] ||
getContext("cc-config")["job_view_nodestats_selectedMetrics"];
for (let metric of sortedJobMetrics) {
// Not Exclusive or Multi-Node: get maxScope directly (mostly: node)
// -> Else: Load smallest available granularity as default as per availability
const availableScopes = scopesForMetric(metric);
if (job.exclusive != 1 || job.numNodes == 1) {
if (availableScopes.includes("accelerator")) {
selectedScopes[metric] = "accelerator";
} else if (availableScopes.includes("core")) {
selectedScopes[metric] = "core";
} else if (availableScopes.includes("socket")) {
selectedScopes[metric] = "socket";
} else {
selectedScopes[metric] = "node";
}
} else {
selectedScopes[metric] = maxScope(availableScopes);
}
sorting[metric] = {
min: { dir: "up", active: false },
avg: { dir: "up", active: false },
max: { dir: "up", active: false },
};
}
function sortBy(metric, stat) {
let s = sorting[metric][stat];
if (s.active) {
s.dir = s.dir == "up" ? "down" : "up";
} else {
for (let metric in sorting)
for (let stat in sorting[metric]) sorting[metric][stat].active = false;
s.active = true;
}
let series = jobMetrics.find(
(jm) => jm.name == metric && jm.scope == "node",
)?.metric.series;
sorting = { ...sorting };
hosts = hosts.sort((h1, h2) => {
let s1 = series.find((s) => s.hostname == h1)?.statistics;
let s2 = series.find((s) => s.hostname == h2)?.statistics;
if (s1 == null || s2 == null) return -1;
return s.dir != "up" ? s1[stat] - s2[stat] : s2[stat] - s1[stat];
});
}
export function moreLoaded(moreJobMetrics) {
moreJobMetrics.forEach(function (newMetric) {
if (!jobMetrics.some((m) => m.scope == newMetric.scope)) {
jobMetrics = [...jobMetrics, newMetric]
}
});
};
</script>
<Row>
<Col class="m-2">
<Button outline on:click={() => (isMetricSelectionOpen = true)} class="w-auto px-2" color="primary">
Select Metrics (Selected {selectedMetrics.length} of {availableMetrics.size} available)
</Button>
</Col>
</Row>
<hr class="mb-1 mt-1"/>
<Table class="mb-0">
<thead>
<!-- Header Row 1: Selectors -->
<tr>
<th/>
{#each selectedMetrics as metric}
<!-- To Match Row-2 Header Field Count-->
<th colspan={selectedScopes[metric] == "node" ? 3 : 4}>
<InputGroup>
<InputGroupText>
{metric}
</InputGroupText>
<Input type="select" bind:value={selectedScopes[metric]}>
{#each scopesForMetric(metric, jobMetrics) as scope}
<option value={scope}>{scope}</option>
{/each}
</Input>
</InputGroup>
</th>
{/each}
</tr>
<!-- Header Row 2: Fields -->
<tr>
<th>Node</th>
{#each selectedMetrics as metric}
{#if selectedScopes[metric] != "node"}
<th>Id</th>
{/if}
{#each ["min", "avg", "max"] as stat}
<th on:click={() => sortBy(metric, stat)}>
{stat}
{#if selectedScopes[metric] == "node"}
<Icon
name="caret-{sorting[metric][stat].dir}{sorting[metric][stat]
.active
? '-fill'
: ''}"
/>
{/if}
</th>
{/each}
{/each}
</tr>
</thead>
<tbody>
{#each hosts as host (host)}
<tr>
<th scope="col">{host}</th>
{#each selectedMetrics as metric (metric)}
<StatsTableEntry
{host}
{metric}
scope={selectedScopes[metric]}
{jobMetrics}
/>
{/each}
</tr>
{/each}
</tbody>
</Table>
<MetricSelection
cluster={job.cluster}
configName="job_view_nodestats_selectedMetrics"
bind:allMetrics={availableMetrics}
bind:metrics={selectedMetrics}
bind:isOpen={isMetricSelectionOpen}
/>

View File

@@ -40,14 +40,14 @@
const client = getContextClient();
const polarQuery = gql`
query ($dbid: ID!, $selectedMetrics: [String!]!) {
jobMetricStats(id: $dbid, metrics: $selectedMetrics) {
jobStats(id: $dbid, metrics: $selectedMetrics) {
name
stats {
min
avg
max
}
data {
min
avg
max
}
}
}
`;
@@ -66,7 +66,7 @@
{:else}
<Polar
{polarMetrics}
polarData={$polarData.data.jobMetricStats}
polarData={$polarData.data.jobStats}
/>
{/if}
</CardBody>

View File

@@ -0,0 +1,139 @@
<!--:
@component Job-View subcomponent; display table of metric data statistics with selectable scopes
Properties:
- `data Object`: The data object
- `selectedMetrics [String]`: The selected metrics
- `hosts [String]`: The list of hostnames of this job
-->
<script>
import {
Table,
Input,
InputGroup,
InputGroupText,
Icon,
} from "@sveltestrap/sveltestrap";
import StatsTableEntry from "./StatsTableEntry.svelte";
export let data = [];
export let selectedMetrics = [];
export let hosts = [];
let sorting = {};
let availableScopes = {};
let selectedScopes = {};
const scopesForMetric = (metric) =>
data?.filter((jm) => jm.name == metric)?.map((jm) => jm.scope) || [];
const setScopeForMetric = (metric, scope) =>
selectedScopes[metric] = scope
$: if (data && selectedMetrics) {
for (let metric of selectedMetrics) {
availableScopes[metric] = scopesForMetric(metric);
// Set Initial Selection, but do not use selectedScopes: Skips reactivity
if (availableScopes[metric].includes("accelerator")) {
setScopeForMetric(metric, "accelerator");
} else if (availableScopes[metric].includes("core")) {
setScopeForMetric(metric, "core");
} else if (availableScopes[metric].includes("socket")) {
setScopeForMetric(metric, "socket");
} else {
setScopeForMetric(metric, "node");
}
sorting[metric] = {
min: { dir: "up", active: false },
avg: { dir: "up", active: false },
max: { dir: "up", active: false },
};
}
}
function sortBy(metric, stat) {
let s = sorting[metric][stat];
if (s.active) {
s.dir = s.dir == "up" ? "down" : "up";
} else {
for (let metric in sorting)
for (let stat in sorting[metric]) sorting[metric][stat].active = false;
s.active = true;
}
let stats = data.find(
(d) => d.name == metric && d.scope == "node",
)?.stats || [];
sorting = { ...sorting };
hosts = hosts.sort((h1, h2) => {
let s1 = stats.find((s) => s.hostname == h1)?.data;
let s2 = stats.find((s) => s.hostname == h2)?.data;
if (s1 == null || s2 == null) return -1;
return s.dir != "up" ? s1[stat] - s2[stat] : s2[stat] - s1[stat];
});
}
</script>
<Table class="mb-0">
<thead>
<!-- Header Row 1: Selectors -->
<tr>
<th/>
{#each selectedMetrics as metric}
<!-- To Match Row-2 Header Field Count-->
<th colspan={selectedScopes[metric] == "node" ? 3 : 4}>
<InputGroup>
<InputGroupText>
{metric}
</InputGroupText>
<Input type="select" bind:value={selectedScopes[metric]} disabled={availableScopes[metric].length === 1}>
{#each (availableScopes[metric] || []) as scope}
<option value={scope}>{scope}</option>
{/each}
</Input>
</InputGroup>
</th>
{/each}
</tr>
<!-- Header Row 2: Fields -->
<tr>
<th>Node</th>
{#each selectedMetrics as metric}
{#if selectedScopes[metric] != "node"}
<th>Id</th>
{/if}
{#each ["min", "avg", "max"] as stat}
<th on:click={() => sortBy(metric, stat)}>
{stat}
{#if selectedScopes[metric] == "node"}
<Icon
name="caret-{sorting[metric][stat].dir}{sorting[metric][stat]
.active
? '-fill'
: ''}"
/>
{/if}
</th>
{/each}
{/each}
</tr>
</thead>
<tbody>
{#each hosts as host (host)}
<tr>
<th scope="col">{host}</th>
{#each selectedMetrics as metric (metric)}
<StatsTableEntry
{data}
{host}
{metric}
scope={selectedScopes[metric]}
/>
{/each}
</tr>
{/each}
</tbody>
</Table>

View File

@@ -1,11 +1,11 @@
<!--
@component Job-View subcomponent; Single Statistics entry component fpr statstable
@component Job-View subcomponent; Single Statistics entry component for statstable
Properties:
- `host String`: The hostname (== node)
- `metric String`: The metric name
- `scope String`: The selected scope
- `jobMetrics [Object]`: The jobs metricdata
- `data [Object]`: The jobs statsdata
-->
<script>
@@ -14,59 +14,61 @@
export let host;
export let metric;
export let scope;
export let jobMetrics;
export let data;
function compareNumbers(a, b) {
return a.id - b.id;
}
function sortByField(field) {
let s = sorting[field];
if (s.active) {
s.dir = s.dir == "up" ? "down" : "up";
} else {
for (let field in sorting) sorting[field].active = false;
s.active = true;
}
sorting = { ...sorting };
series = series.sort((a, b) => {
if (a == null || b == null) return -1;
if (field === "id") {
return s.dir != "up" ? a[field] - b[field] : b[field] - a[field];
} else {
return s.dir != "up"
? a.statistics[field] - b.statistics[field]
: b.statistics[field] - a.statistics[field];
}
});
}
let sorting = {
let entrySorting = {
id: { dir: "down", active: true },
min: { dir: "up", active: false },
avg: { dir: "up", active: false },
max: { dir: "up", active: false },
};
$: series = jobMetrics
.find((jm) => jm.name == metric && jm.scope == scope)
?.metric.series.filter((s) => s.hostname == host && s.statistics != null)
?.sort(compareNumbers);
function compareNumbers(a, b) {
return a.id - b.id;
}
function sortByField(field) {
let s = entrySorting[field];
if (s.active) {
s.dir = s.dir == "up" ? "down" : "up";
} else {
for (let field in entrySorting) entrySorting[field].active = false;
s.active = true;
}
entrySorting = { ...entrySorting };
stats = stats.sort((a, b) => {
if (a == null || b == null) return -1;
if (field === "id") {
return s.dir != "up" ?
a[field].localeCompare(b[field], undefined, {numeric: true, sensitivity: 'base'}) :
b[field].localeCompare(a[field], undefined, {numeric: true, sensitivity: 'base'})
} else {
return s.dir != "up"
? a.data[field] - b.data[field]
: b.data[field] - a.data[field];
}
});
}
$: stats = data
?.find((d) => d.name == metric && d.scope == scope)
?.stats.filter((s) => s.hostname == host && s.data != null)
?.sort(compareNumbers) || [];
</script>
{#if series == null || series.length == 0}
{#if stats == null || stats.length == 0}
<td colspan={scope == "node" ? 3 : 4}><i>No data</i></td>
{:else if series.length == 1 && scope == "node"}
{:else if stats.length == 1 && scope == "node"}
<td>
{series[0].statistics.min}
{stats[0].data.min}
</td>
<td>
{series[0].statistics.avg}
{stats[0].data.avg}
</td>
<td>
{series[0].statistics.max}
{stats[0].data.max}
</td>
{:else}
<td colspan="4">
@@ -77,19 +79,19 @@
<th on:click={() => sortByField(field)}>
Sort
<Icon
name="caret-{sorting[field].dir}{sorting[field].active
name="caret-{entrySorting[field].dir}{entrySorting[field].active
? '-fill'
: ''}"
/>
</th>
{/each}
</tr>
{#each series as s, i}
{#each stats as s, i}
<tr>
<th>{s.id ?? i}</th>
<td>{s.statistics.min}</td>
<td>{s.statistics.avg}</td>
<td>{s.statistics.max}</td>
<td>{s.data.min}</td>
<td>{s.data.avg}</td>
<td>{s.data.max}</td>
</tr>
{/each}
</tbody>

View File

@@ -205,7 +205,7 @@
</Col>
</Row>
{:else}
{#each nodes as nodeData}
{#each nodes as nodeData (nodeData.host)}
<NodeListRow {nodeData} {cluster} {selectedMetrics}/>
{:else}
<tr>
@@ -221,7 +221,7 @@
<p><b>
Loading nodes {nodes.length + 1} to
{ matchedNodes
? `${((nodes.length + paging.itemsPerPage) > matchedNodes) ? matchedNodes : (nodes.length + paging.itemsPerPage)} of ${matchedNodes} total`
? `${(nodes.length + paging.itemsPerPage) > matchedNodes ? matchedNodes : (nodes.length + paging.itemsPerPage)} of ${matchedNodes} total`
: (nodes.length + paging.itemsPerPage)
}
</b></p>

View File

@@ -17,6 +17,9 @@
Input,
InputGroup,
InputGroupText, } from "@sveltestrap/sveltestrap";
import {
scramble,
scrambleNames, } from "../../generic/utils.js";
export let cluster;
export let subCluster
@@ -32,8 +35,8 @@
let userList;
let projectList;
$: if (nodeJobsData) {
userList = Array.from(new Set(nodeJobsData.jobs.items.map((j) => j.user))).sort((a, b) => a.localeCompare(b));
projectList = Array.from(new Set(nodeJobsData.jobs.items.map((j) => j.project))).sort((a, b) => a.localeCompare(b));
userList = Array.from(new Set(nodeJobsData.jobs.items.map((j) => scrambleNames ? scramble(j.user) : j.user))).sort((a, b) => a.localeCompare(b));
projectList = Array.from(new Set(nodeJobsData.jobs.items.map((j) => scrambleNames ? scramble(j.project) : j.project))).sort((a, b) => a.localeCompare(b));
}
</script>
@@ -102,6 +105,19 @@
Shared
</Button>
</InputGroup>
<!-- Fallback -->
{:else if nodeJobsData.jobs.count >= 1}
<InputGroup>
<InputGroupText>
<Icon name="circle-fill"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="success" disabled>
Allocated Jobs
</Button>
</InputGroup>
{:else}
<InputGroup>
<InputGroupText>

View File

@@ -14,7 +14,7 @@
getContextClient,
} from "@urql/svelte";
import { Card, CardBody, Spinner } from "@sveltestrap/sveltestrap";
import { maxScope, checkMetricDisabled } from "../../generic/utils.js";
import { maxScope, checkMetricDisabled, scramble, scrambleNames } from "../../generic/utils.js";
import MetricPlot from "../../generic/plots/MetricPlot.svelte";
import NodeInfo from "./NodeInfo.svelte";
@@ -98,21 +98,24 @@
let extendedLegendData = null;
$: if ($nodeJobsData?.data) {
// Get Shared State of Node: Only Build extended Legend For Shared Nodes
if ($nodeJobsData.data.jobs.count >= 1 && !$nodeJobsData.data.jobs.items[0].exclusive) {
// Build Extended for allocated nodes [Commented: Only Build extended Legend For Shared Nodes]
if ($nodeJobsData.data.jobs.count >= 1) { // "&& !$nodeJobsData.data.jobs.items[0].exclusive)"
const accSet = Array.from(new Set($nodeJobsData.data.jobs.items
.map((i) => i.resources
.filter((r) => r.hostname === nodeData.host)
.map((r) => r.accelerators)
.filter((r) => (r.hostname === nodeData.host) && r?.accelerators)
.map((r) => r?.accelerators)
)
)).flat(2)
extendedLegendData = {}
for (const accId of accSet) {
const matchJob = $nodeJobsData.data.jobs.items.find((i) => i.resources.find((r) => r.accelerators.includes(accId)))
const matchUser = matchJob?.user ? matchJob.user : null
extendedLegendData[accId] = {
user: matchJob?.user ? matchJob?.user : '-',
job: matchJob?.jobId ? matchJob?.jobId : '-',
user: (scrambleNames && matchUser)
? scramble(matchUser)
: (matchUser ? matchUser : '-'),
job: matchJob?.jobId ? matchJob.jobId : '-',
}
}
// Theoretically extendable for hwthreadIDs

View File

@@ -0,0 +1,15 @@
import { mount } from 'svelte';
import {} from './header.entrypoint.js'
import Tags from './Tags.root.svelte'
mount(Tags, {
target: document.getElementById('svelte-app'),
props: {
username: username,
isAdmin: isAdmin,
tagmap: tagmap,
},
context: new Map([
['cc-config', clusterCockpitConfig]
])
})