Restructure frontend svelte file src folder

- Goal: Dependency structure mirrored in file structure
This commit is contained in:
Christoph Kluge
2024-07-26 12:34:18 +02:00
parent 18369da5bc
commit 3ca1127685
52 changed files with 142 additions and 109 deletions

View File

@@ -0,0 +1,431 @@
<!--
@component Main filter component; handles filter object on sub-component changes before dispatching it
Properties:
- `menuText String?`: Optional text to show in the dropdown menu [Default: null]
- `filterPresets Object?`: Optional predefined filter values [Default: {}]
- `disableClusterSelection Bool?`: Is the selection disabled [Default: false]
- `startTimeQuickSelect Bool?`: Render startTime quick selections [Default: false]
Events:
- `update-filters, {filters: [Object]?}`: The detail's 'filters' prop are new filter items to be applied
Functions:
- `void updateFilters (additionalFilters: Object?)`: Handles new filters from nested components, triggers upstream update event
-->
<script>
import { createEventDispatcher } from "svelte";
import {
Row,
Col,
DropdownItem,
DropdownMenu,
DropdownToggle,
ButtonDropdown,
Icon,
} from "@sveltestrap/sveltestrap";
import Tag from "./helper/Tag.svelte";
import Info from "./filters/InfoBox.svelte";
import Cluster from "./filters/Cluster.svelte";
import JobStates, { allJobStates } from "./filters/JobStates.svelte";
import StartTime from "./filters/StartTime.svelte";
import Tags from "./filters/Tags.svelte";
import Duration from "./filters/Duration.svelte";
import Resources from "./filters/Resources.svelte";
import Statistics from "./filters/Stats.svelte";
const dispatch = createEventDispatcher();
export let menuText = null;
export let filterPresets = {};
export let disableClusterSelection = false;
export let startTimeQuickSelect = false;
let filters = {
projectMatch: filterPresets.projectMatch || "contains",
userMatch: filterPresets.userMatch || "contains",
jobIdMatch: filterPresets.jobIdMatch || "eq",
cluster: filterPresets.cluster || null,
partition: filterPresets.partition || null,
states:
filterPresets.states || filterPresets.state
? [filterPresets.state].flat()
: allJobStates,
startTime: filterPresets.startTime || { from: null, to: null },
tags: filterPresets.tags || [],
duration: filterPresets.duration || {
lessThan: null,
moreThan: null,
from: null,
to: null,
},
jobId: filterPresets.jobId || "",
arrayJobId: filterPresets.arrayJobId || null,
user: filterPresets.user || "",
project: filterPresets.project || "",
jobName: filterPresets.jobName || "",
node: filterPresets.node || null,
numNodes: filterPresets.numNodes || { from: null, to: null },
numHWThreads: filterPresets.numHWThreads || { from: null, to: null },
numAccelerators: filterPresets.numAccelerators || { from: null, to: null },
stats: [],
};
let isClusterOpen = false,
isJobStatesOpen = false,
isStartTimeOpen = false,
isTagsOpen = false,
isDurationOpen = false,
isResourcesOpen = false,
isStatsOpen = false,
isNodesModified = false,
isHwthreadsModified = false,
isAccsModified = false;
// Can be called from the outside to trigger a 'update' event from this component.
export function updateFilters(additionalFilters = null) {
if (additionalFilters != null)
for (let key in additionalFilters) filters[key] = additionalFilters[key];
let items = [];
if (filters.cluster) items.push({ cluster: { eq: filters.cluster } });
if (filters.node) items.push({ node: { contains: filters.node } });
if (filters.partition) items.push({ partition: { eq: filters.partition } });
if (filters.states.length != allJobStates.length)
items.push({ state: filters.states });
if (filters.startTime.from || filters.startTime.to)
items.push({
startTime: { from: filters.startTime.from, to: filters.startTime.to },
});
if (filters.tags.length != 0) items.push({ tags: filters.tags });
if (filters.duration.from || filters.duration.to)
items.push({
duration: { from: filters.duration.from, to: filters.duration.to },
});
if (filters.duration.lessThan)
items.push({ duration: { from: 0, to: filters.duration.lessThan } });
if (filters.duration.moreThan)
items.push({ duration: { from: filters.duration.moreThan, to: 604800 } }); // 7 days to include special jobs with long runtimes
if (filters.jobId)
items.push({ jobId: { [filters.jobIdMatch]: filters.jobId } });
if (filters.arrayJobId != null)
items.push({ arrayJobId: filters.arrayJobId });
if (filters.numNodes.from != null || filters.numNodes.to != null)
items.push({
numNodes: { from: filters.numNodes.from, to: filters.numNodes.to },
});
if (filters.numHWThreads.from != null || filters.numHWThreads.to != null)
items.push({
numHWThreads: {
from: filters.numHWThreads.from,
to: filters.numHWThreads.to,
},
});
if (
filters.numAccelerators.from != null ||
filters.numAccelerators.to != null
)
items.push({
numAccelerators: {
from: filters.numAccelerators.from,
to: filters.numAccelerators.to,
},
});
if (filters.user)
items.push({ user: { [filters.userMatch]: filters.user } });
if (filters.project)
items.push({ project: { [filters.projectMatch]: filters.project } });
if (filters.jobName) items.push({ jobName: { contains: filters.jobName } });
if (filters.stats.length != 0)
items.push({ metricStats: filters.stats.map((st) => { return { metricName: st.field, range: { from: st.from, to: st.to }} }) });
dispatch("update-filters", { filters: items });
changeURL();
return items;
}
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.partition) opts.push(`partition=${filters.partition}`);
if (filters.states.length != allJobStates.length)
for (let state of filters.states) opts.push(`state=${state}`);
if (filters.startTime.from && filters.startTime.to)
// if (filters.startTime.url) {
// opts.push(`startTime=${filters.startTime.url}`)
// } else {
opts.push(
`startTime=${dateToUnixEpoch(filters.startTime.from)}-${dateToUnixEpoch(filters.startTime.to)}`,
);
// }
if (filters.jobId.length != 0)
if (filters.jobIdMatch != "in") {
opts.push(`jobId=${filters.jobId}`);
} else {
for (let singleJobId of filters.jobId)
opts.push(`jobId=${singleJobId}`);
}
if (filters.jobIdMatch != "eq")
opts.push(`jobIdMatch=${filters.jobIdMatch}`);
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}`);
if (filters.duration.lessThan)
opts.push(`duration=0-${filters.duration.lessThan}`);
if (filters.duration.moreThan)
opts.push(`duration=${filters.duration.moreThan}-604800`);
if (filters.numNodes.from && filters.numNodes.to)
opts.push(`numNodes=${filters.numNodes.from}-${filters.numNodes.to}`);
if (filters.numAccelerators.from && filters.numAccelerators.to)
opts.push(
`numAccelerators=${filters.numAccelerators.from}-${filters.numAccelerators.to}`,
);
if (filters.user.length != 0)
if (filters.userMatch != "in") {
opts.push(`user=${filters.user}`);
} else {
for (let singleUser of filters.user) opts.push(`user=${singleUser}`);
}
if (filters.userMatch != "contains")
opts.push(`userMatch=${filters.userMatch}`);
if (filters.project) opts.push(`project=${filters.project}`);
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 (opts.length == 0 && window.location.search.length <= 1) return;
let newurl = `${window.location.pathname}?${opts.join("&")}`;
window.history.replaceState(null, "", newurl);
}
</script>
<Row>
<Col xs="auto">
<ButtonDropdown class="cc-dropdown-on-hover">
<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={() => (isStatsOpen = true)}>
<Icon name="bar-chart" on:click={() => (isStatsOpen = true)} /> Statistics
</DropdownItem>
{#if startTimeQuickSelect}
<DropdownItem divider />
<DropdownItem disabled>Start Time Qick Selection</DropdownItem>
{#each [{ text: "Last 6hrs", url: "last6h", seconds: 6 * 60 * 60 }, { text: "Last 24hrs", url: "last24h", seconds: 24 * 60 * 60 }, { text: "Last 7 days", url: "last7d", seconds: 7 * 24 * 60 * 60 }, { text: "Last 30 days", url: "last30d", seconds: 30 * 24 * 60 * 60 }] as { text, url, seconds }}
<DropdownItem
on:click={() => {
filters.startTime.from = new Date(
Date.now() - seconds * 1000,
).toISOString();
filters.startTime.to = new Date(Date.now()).toISOString();
(filters.startTime.text = text), (filters.startTime.url = url);
updateFilters();
}}
>
<Icon name="calendar-range" />
{text}
</DropdownItem>
{/each}
{/if}
</DropdownMenu>
</ButtonDropdown>
</Col>
<Col xs="auto">
{#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.startTime.from || filters.startTime.to}
<Info icon="calendar-range" on:click={() => (isStartTimeOpen = true)}>
{#if filters.startTime.text}
{filters.startTime.text}
{:else}
{new Date(filters.startTime.from).toLocaleString()} - {new Date(
filters.startTime.to,
).toLocaleString()}
{/if}
</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.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}
<Tag id={tagId} clickable={false} />
{/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.node != null}
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
Node: {filters.node}
</Info>
{/if}
{#if filters.stats.length > 0}
<Info icon="bar-chart" on:click={() => (isStatsOpen = true)}>
{filters.stats
.map((stat) => `${stat.text}: ${stat.from} - ${stat.to}`)
.join(", ")}
</Info>
{/if}
</Col>
</Row>
<Cluster
{disableClusterSelection}
bind:isOpen={isClusterOpen}
bind:cluster={filters.cluster}
bind:partition={filters.partition}
on:set-filter={() => updateFilters()}
/>
<JobStates
bind:isOpen={isJobStatesOpen}
bind:states={filters.states}
on:set-filter={() => updateFilters()}
/>
<StartTime
bind:isOpen={isStartTimeOpen}
bind:from={filters.startTime.from}
bind:to={filters.startTime.to}
on:set-filter={() => {
delete filters.startTime["text"];
delete filters.startTime["url"];
updateFilters();
}}
/>
<Duration
bind:isOpen={isDurationOpen}
bind:lessThan={filters.duration.lessThan}
bind:moreThan={filters.duration.moreThan}
bind:from={filters.duration.from}
bind:to={filters.duration.to}
on:set-filter={() => updateFilters()}
/>
<Tags
bind:isOpen={isTagsOpen}
bind:tags={filters.tags}
on:set-filter={() => updateFilters()}
/>
<Resources
cluster={filters.cluster}
bind:isOpen={isResourcesOpen}
bind:numNodes={filters.numNodes}
bind:numHWThreads={filters.numHWThreads}
bind:numAccelerators={filters.numAccelerators}
bind:namedNode={filters.node}
bind:isNodesModified
bind:isHwthreadsModified
bind:isAccsModified
on:set-filter={() => updateFilters()}
/>
<Statistics
bind:isOpen={isStatsOpen}
bind:stats={filters.stats}
on:set-filter={() => updateFilters()}
/>
<style>
:global(.cc-dropdown-on-hover:hover .dropdown-menu) {
display: block;
margin-top: 0px;
padding-top: 0px;
transform: none !important;
}
</style>

View File

@@ -0,0 +1,321 @@
<!--
@component Main jobList component; lists jobs according to set filters
Properties:
- `sorting Object?`: Currently active sorting [Default: {field: "startTime", type: "col", order: "DESC"}]
- `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:
- `refreshJobs()`: Load jobs data with unchanged parameters and 'network-only' keyword
- `refreshAllMetrics()`: Trigger downstream refresh of all running jobs' metric data
- `queryJobs(filters?: [JobFilter])`: Load jobs data with new filters, starts from page 1
-->
<script>
import { getContext } from "svelte";
import {
queryStore,
gql,
getContextClient,
mutationStore,
} from "@urql/svelte";
import { Row, Table, Card, Spinner } from "@sveltestrap/sveltestrap";
import { stickyHeader } from "./utils.js";
import Pagination from "./joblist/Pagination.svelte";
import JobListRow from "./joblist/JobListRow.svelte";
const ccconfig = getContext("cc-config"),
initialized = getContext("initialized"),
globalMetrics = getContext("globalMetrics");
export let sorting = { field: "startTime", type: "col", order: "DESC" };
export let matchedJobs = 0;
export let metrics = ccconfig.plot_list_selectedMetrics;
export let showFootprint;
let usePaging = ccconfig.job_list_usePaging
let itemsPerPage = usePaging ? ccconfig.plot_list_jobsPerPage : 10;
let page = 1;
let paging = { itemsPerPage, page };
let filter = [];
let triggerMetricRefresh = false;
function getUnit(m) {
const rawUnit = globalMetrics.find((gm) => gm.name === m)?.unit
return (rawUnit?.prefix ? rawUnit.prefix : "") + (rawUnit?.base ? rawUnit.base : "")
}
const client = getContextClient();
const query = gql`
query (
$filter: [JobFilter!]!
$sorting: OrderByInput!
$paging: PageRequest!
) {
jobs(filter: $filter, order: $sorting, page: $paging) {
items {
id
jobId
user
project
cluster
subCluster
startTime
duration
numNodes
numHWThreads
numAcc
walltime
resources {
hostname
}
SMT
exclusive
partition
arrayJobId
monitoringStatus
state
tags {
id
type
name
}
userData {
name
}
metaData
footprint {
name
stat
value
}
}
count
hasNextPage
}
}
`;
$: jobsStore = queryStore({
client: client,
query: query,
variables: { paging, sorting, filter },
});
let jobs = []
$: if ($initialized && $jobsStore.data) {
jobs = [...$jobsStore.data.jobs.items]
}
$: matchedJobs = $jobsStore.data != null ? $jobsStore.data.jobs.count : 0;
// Force refresh list with existing unchanged variables (== usually would not trigger reactivity)
export function refreshJobs() {
jobsStore = queryStore({
client: client,
query: query,
variables: { paging, sorting, filter },
requestPolicy: "network-only",
});
}
export function refreshAllMetrics() {
// Refresh Job Metrics (Downstream will only query for running jobs)
triggerMetricRefresh = true
setTimeout(function () {
triggerMetricRefresh = false;
}, 100);
}
// (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;
}
page = 1;
paging = paging = { page, itemsPerPage };
}
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;
}
});
}
if (!usePaging) {
let scrollMultiplier = 1
window.addEventListener('scroll', () => {
let {
scrollTop,
scrollHeight,
clientHeight
} = document.documentElement;
// Add 100 px offset to trigger load earlier
if (scrollTop + clientHeight >= scrollHeight - 100 && $jobsStore.data != null && $jobsStore.data.jobs.hasNextPage) {
let pendingPaging = { ...paging }
scrollMultiplier += 1
pendingPaging.itemsPerPage = itemsPerPage * scrollMultiplier
paging = pendingPaging
};
});
};
let plotWidth = null;
let tableWidth = null;
let jobInfoColumnWidth = 250;
$: if (showFootprint) {
plotWidth = Math.floor(
(tableWidth - jobInfoColumnWidth) / (metrics.length + 1) - 10,
);
} else {
plotWidth = Math.floor(
(tableWidth - jobInfoColumnWidth) / metrics.length - 10,
);
}
let headerPaddingTop = 0;
stickyHeader(
".cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)",
(x) => (headerPaddingTop = x),
);
</script>
<Row>
<div class="col cc-table-wrapper" bind:clientWidth={tableWidth}>
<Table cellspacing="0px" cellpadding="0px">
<thead>
<tr>
<th
class="position-sticky top-0"
scope="col"
style="width: {jobInfoColumnWidth}px; padding-top: {headerPaddingTop}px"
>
Job Info
</th>
{#if showFootprint}
<th
class="position-sticky top-0"
scope="col"
style="width: {plotWidth}px; padding-top: {headerPaddingTop}px"
>
Job Footprint
</th>
{/if}
{#each metrics as metric (metric)}
<th
class="position-sticky top-0 text-center"
scope="col"
style="width: {plotWidth}px; padding-top: {headerPaddingTop}px"
>
{metric}
{#if $initialized}
({getUnit(metric)})
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#if $jobsStore.error}
<tr>
<td colspan={metrics.length + 1}>
<Card body color="danger" class="mb-3"
><h2>{$jobsStore.error.message}</h2></Card
>
</td>
</tr>
{:else}
{#each jobs as job (job)}
<JobListRow bind:triggerMetricRefresh {job} {metrics} {plotWidth} {showFootprint} />
{:else}
<tr>
<td colspan={metrics.length + 1}> No jobs found </td>
</tr>
{/each}
{/if}
{#if $jobsStore.fetching || !$jobsStore.data}
<tr>
<td colspan={metrics.length + 1}>
<div style="text-align:center;">
<Spinner secondary />
</div>
</td>
</tr>
{/if}
</tbody>
</Table>
</div>
</Row>
{#if usePaging}
<Pagination
bind:page
{itemsPerPage}
itemText="Jobs"
totalItems={matchedJobs}
on:update-paging={({ detail }) => {
if (detail.itemsPerPage != itemsPerPage) {
updateConfiguration(detail.itemsPerPage.toString(), detail.page);
} else {
jobs = []
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page };
}
}}
/>
{/if}
<style>
.cc-table-wrapper {
overflow: initial;
}
.cc-table-wrapper > :global(table) {
border-collapse: separate;
border-spacing: 0px;
table-layout: fixed;
}
.cc-table-wrapper :global(button) {
margin-bottom: 0px;
}
.cc-table-wrapper > :global(table > tbody > tr > td) {
margin: 0px;
padding-left: 5px;
padding-right: 0px;
}
th.position-sticky.top-0 {
background-color: white;
z-index: 10;
border-bottom: 1px solid black;
}
</style>

View File

@@ -0,0 +1,59 @@
<!--
@component Organized display of plots as table
Properties:
- `itemsPerRow Number`: Elements to render per row
- `items [Any]`: List of plot components to render
- `padding Number`: Padding between plot elements
- `renderFor String`: If 'job', filter disabled metrics
-->
<script>
export let itemsPerRow
export let items
export let padding = 10
export let renderFor
let rows = []
let tableWidth = 0
const isPlaceholder = x => x._is_placeholder === true
function tile(items, itemsPerRow) {
const rows = []
for (let ri = 0; ri < items.length; ri += itemsPerRow) {
const row = []
for (let ci = 0; ci < itemsPerRow; ci += 1) {
if (ri + ci < items.length)
row.push(items[ri + ci])
else
row.push({ _is_placeholder: true, ri, ci })
}
rows.push(row)
}
return rows
}
$: if (renderFor === 'job') {
rows = tile(items.filter(item => item.disabled === false), itemsPerRow)
} else {
rows = tile(items, itemsPerRow)
}
$: plotWidth = (tableWidth / itemsPerRow) - (padding * itemsPerRow)
</script>
<table bind:clientWidth={tableWidth} style="width: 100%; table-layout: fixed;">
{#each rows as row}
<tr>
{#each row as item (item)}
<td style="vertical-align:top;"> <!-- For Aligning Notice Cards -->
{#if !isPlaceholder(item) && plotWidth > 0}
<slot item={item} width={plotWidth}></slot>
{/if}
</td>
{/each}
</tr>
{/each}
</table>

View File

@@ -0,0 +1,72 @@
import { filter, map, merge, pipe, share, tap } from 'wonka';
/*
* Alternative to the default cacheExchange from urql (A GraphQL client).
* Mutations do not invalidate cached results, so in that regard, this
* implementation is inferior to the default one. Most people should probably
* use the standard cacheExchange and @urql/exchange-request-policy. This cache
* also ignores the 'network-and-cache' request policy.
*
* Options:
* ttl: How long queries are allowed to be cached (in milliseconds)
* maxSize: Max number of results cached. The oldest queries are removed first.
*/
export const expiringCacheExchange = ({ ttl, maxSize }) => ({ forward }) => {
const cache = new Map();
const isCached = (operation) => {
if (operation.kind !== 'query' || operation.context.requestPolicy === 'network-only')
return false;
if (!cache.has(operation.key))
return false;
let cacheEntry = cache.get(operation.key);
return Date.now() < cacheEntry.expiresAt;
};
return operations => {
let shared = share(operations);
return merge([
pipe(
shared,
filter(operation => isCached(operation)),
map(operation => cache.get(operation.key).response)
),
pipe(
shared,
filter(operation => !isCached(operation)),
forward,
tap(response => {
if (!response.operation || response.operation.kind !== 'query')
return;
if (!response.data)
return;
let now = Date.now();
for (let cacheEntry of cache.values()) {
if (cacheEntry.expiresAt < now) {
cache.delete(cacheEntry.response.operation.key);
}
}
if (cache.size > maxSize) {
let n = cache.size - maxSize + 1;
for (let key of cache.keys()) {
if (n-- == 0)
break;
cache.delete(key);
}
}
cache.set(response.operation.key, {
expiresAt: now + ttl,
response: response
});
})
)
]);
};
};

View File

@@ -0,0 +1,109 @@
<!--
@component Filter sub-component for selecting cluster and subCluster
Properties:
- `disableClusterSelection Bool?`: Is the selection disabled [Default: false]
- `isModified Bool?`: Is this filter component modified [Default: false]
- `isOpen Bool?`: Is this filter component opened [Default: false]
- `cluster String?`: The currently selected cluster [Default: null]
- `partition String?`: The currently selected partition (i.e. subCluster) [Default: null]
Events:
- `set-filter, {String?, String?}`: Set 'cluster, subCluster' filter in upstream component
-->
<script>
import { createEventDispatcher, getContext } from "svelte";
import {
Button,
ListGroup,
ListGroupItem,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
} from "@sveltestrap/sveltestrap";
const clusters = getContext("clusters"),
initialized = getContext("initialized"),
dispatch = createEventDispatcher();
export let disableClusterSelection = false;
export let isModified = false;
export let isOpen = false;
export let cluster = null;
export let partition = null;
let pendingCluster = cluster,
pendingPartition = partition;
$: isModified = pendingCluster != cluster || pendingPartition != partition;
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>Select Cluster & Slurm Partition</ModalHeader>
<ModalBody>
{#if $initialized}
<h4>Cluster</h4>
<ListGroup>
<ListGroupItem
disabled={disableClusterSelection}
active={pendingCluster == null}
on:click={() => ((pendingCluster = null), (pendingPartition = null))}
>
Any Cluster
</ListGroupItem>
{#each clusters as cluster}
<ListGroupItem
disabled={disableClusterSelection}
active={pendingCluster == cluster.name}
on:click={() => (
(pendingCluster = cluster.name), (pendingPartition = null)
)}
>
{cluster.name}
</ListGroupItem>
{/each}
</ListGroup>
{/if}
{#if $initialized && pendingCluster != null}
<br />
<h4>Partiton</h4>
<ListGroup>
<ListGroupItem
active={pendingPartition == null}
on:click={() => (pendingPartition = null)}
>
Any Partition
</ListGroupItem>
{#each clusters.find((c) => c.name == pendingCluster).partitions as partition}
<ListGroupItem
active={pendingPartition == partition}
on:click={() => (pendingPartition = partition)}
>
{partition}
</ListGroupItem>
{/each}
</ListGroup>
{/if}
</ModalBody>
<ModalFooter>
<Button
color="primary"
on:click={() => {
isOpen = false;
cluster = pendingCluster;
partition = pendingPartition;
dispatch("set-filter", { cluster, partition });
}}>Close & Apply</Button
>
<Button
color="danger"
on:click={() => {
isOpen = false;
cluster = pendingCluster = null;
partition = pendingPartition = null;
dispatch("set-filter", { cluster, partition });
}}>Reset</Button
>
<Button on:click={() => (isOpen = false)}>Close</Button>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,258 @@
<!--
@component Filter sub-component for selecting job duration
Properties:
- `isOpen Bool?`: Is this filter component opened [Default: false]
- `lessThan Number?`: Amount of seconds [Default: null]
- `moreThan Number?`: Amount of seconds [Default: null]
- `from Number?`: Epoch time in seconds [Default: null]
- `to Number?`: Epoch time in seconds [Default: null]
Events:
- `set-filter, {Number, Number, Number, Number}`: Set 'lessThan, moreThan, from, to' filter in upstream component
-->
<script>
import { createEventDispatcher } from "svelte";
import {
Row,
Col,
Button,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
} from "@sveltestrap/sveltestrap";
const dispatch = createEventDispatcher();
export let isOpen = false;
export let lessThan = null;
export let moreThan = null;
export let from = null;
export let to = null;
let pendingLessThan, pendingMoreThan, pendingFrom, pendingTo;
let lessDisabled = false,
moreDisabled = false,
betweenDisabled = false;
function reset() {
pendingLessThan =
lessThan == null ? { hours: 0, mins: 0 } : secsToHoursAndMins(lessThan);
pendingMoreThan =
moreThan == null ? { hours: 0, mins: 0 } : secsToHoursAndMins(moreThan);
pendingFrom =
from == null ? { hours: 0, mins: 0 } : secsToHoursAndMins(from);
pendingTo = to == null ? { hours: 0, mins: 0 } : secsToHoursAndMins(to);
}
reset();
function secsToHoursAndMins(duration) {
const hours = Math.floor(duration / 3600);
duration -= hours * 3600;
const mins = Math.floor(duration / 60);
return { hours, mins };
}
function hoursAndMinsToSecs({ hours, mins }) {
return hours * 3600 + mins * 60;
}
$: lessDisabled =
pendingMoreThan.hours !== 0 ||
pendingMoreThan.mins !== 0 ||
pendingFrom.hours !== 0 ||
pendingFrom.mins !== 0 ||
pendingTo.hours !== 0 ||
pendingTo.mins !== 0;
$: moreDisabled =
pendingLessThan.hours !== 0 ||
pendingLessThan.mins !== 0 ||
pendingFrom.hours !== 0 ||
pendingFrom.mins !== 0 ||
pendingTo.hours !== 0 ||
pendingTo.mins !== 0;
$: betweenDisabled =
pendingMoreThan.hours !== 0 ||
pendingMoreThan.mins !== 0 ||
pendingLessThan.hours !== 0 ||
pendingLessThan.mins !== 0;
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>Select Job Duration</ModalHeader>
<ModalBody>
<h4>Duration more than</h4>
<Row>
<Col>
<div class="input-group mb-2 mr-sm-2">
<input
type="number"
min="0"
class="form-control"
bind:value={pendingMoreThan.hours}
disabled={moreDisabled}
/>
<div class="input-group-append">
<div class="input-group-text">h</div>
</div>
</div>
</Col>
<Col>
<div class="input-group mb-2 mr-sm-2">
<input
type="number"
min="0"
max="59"
class="form-control"
bind:value={pendingMoreThan.mins}
disabled={moreDisabled}
/>
<div class="input-group-append">
<div class="input-group-text">m</div>
</div>
</div>
</Col>
</Row>
<hr />
<h4>Duration less than</h4>
<Row>
<Col>
<div class="input-group mb-2 mr-sm-2">
<input
type="number"
min="0"
class="form-control"
bind:value={pendingLessThan.hours}
disabled={lessDisabled}
/>
<div class="input-group-append">
<div class="input-group-text">h</div>
</div>
</div>
</Col>
<Col>
<div class="input-group mb-2 mr-sm-2">
<input
type="number"
min="0"
max="59"
class="form-control"
bind:value={pendingLessThan.mins}
disabled={lessDisabled}
/>
<div class="input-group-append">
<div class="input-group-text">m</div>
</div>
</div>
</Col>
</Row>
<hr />
<h4>Duration between</h4>
<Row>
<Col>
<div class="input-group mb-2 mr-sm-2">
<input
type="number"
min="0"
class="form-control"
bind:value={pendingFrom.hours}
disabled={betweenDisabled}
/>
<div class="input-group-append">
<div class="input-group-text">h</div>
</div>
</div>
</Col>
<Col>
<div class="input-group mb-2 mr-sm-2">
<input
type="number"
min="0"
max="59"
class="form-control"
bind:value={pendingFrom.mins}
disabled={betweenDisabled}
/>
<div class="input-group-append">
<div class="input-group-text">m</div>
</div>
</div>
</Col>
</Row>
<h4>and</h4>
<Row>
<Col>
<div class="input-group mb-2 mr-sm-2">
<input
type="number"
min="0"
class="form-control"
bind:value={pendingTo.hours}
disabled={betweenDisabled}
/>
<div class="input-group-append">
<div class="input-group-text">h</div>
</div>
</div>
</Col>
<Col>
<div class="input-group mb-2 mr-sm-2">
<input
type="number"
min="0"
max="59"
class="form-control"
bind:value={pendingTo.mins}
disabled={betweenDisabled}
/>
<div class="input-group-append">
<div class="input-group-text">m</div>
</div>
</div>
</Col>
</Row>
</ModalBody>
<ModalFooter>
<Button
color="primary"
on:click={() => {
isOpen = false;
lessThan = hoursAndMinsToSecs(pendingLessThan);
moreThan = hoursAndMinsToSecs(pendingMoreThan);
from = hoursAndMinsToSecs(pendingFrom);
to = hoursAndMinsToSecs(pendingTo);
dispatch("set-filter", { lessThan, moreThan, from, to });
}}
>
Close & Apply
</Button>
<Button
color="warning"
on:click={() => {
lessThan = null;
moreThan = null;
from = null;
to = null;
reset();
}}>Reset Values</Button
>
<Button
color="danger"
on:click={() => {
isOpen = false;
lessThan = null;
moreThan = null;
from = null;
to = null;
reset();
dispatch("set-filter", { lessThan, moreThan, from, to });
}}>Reset Filter</Button
>
<Button on:click={() => (isOpen = false)}>Close</Button>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,19 @@
<!--
@component Info pill displayed for active filters
Properties:
- `icon String`: Sveltestrap icon name
- `modified Bool?`: Optional if filter is modified [Default: false]
-->
<script>
import { Button, Icon } from "@sveltestrap/sveltestrap";
export let icon;
export let modified = false;
</script>
<Button outline color={modified ? "warning" : "primary"} on:click>
<Icon name={icon} />
<slot />
</Button>

View File

@@ -0,0 +1,91 @@
<!--
@component Filter sub-component for selecting job states
Properties:
- `isModified Bool?`: Is this filter component modified [Default: false]
- `isOpen Bool?`: Is this filter component opened [Default: false]
- `states [String]?`: The currently selected states [Default: [...allJobStates]]
Events:
- `set-filter, {[String]}`: Set 'states' filter in upstream component
Exported:
- `const allJobStates [String]`: List of all available job states used in cc-backend
-->
<script context="module">
export const allJobStates = [
"running",
"completed",
"failed",
"cancelled",
"stopped",
"timeout",
"preempted",
"out_of_memory",
];
</script>
<script>
import { createEventDispatcher } from "svelte";
import {
Button,
ListGroup,
ListGroupItem,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
} from "@sveltestrap/sveltestrap";
const dispatch = createEventDispatcher();
export let isModified = false;
export let isOpen = false;
export let states = [...allJobStates];
let pendingStates = [...states];
$: isModified =
states.length != pendingStates.length ||
!states.every((state) => pendingStates.includes(state));
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>Select Job States</ModalHeader>
<ModalBody>
<ListGroup>
{#each allJobStates as state}
<ListGroupItem>
<input
type="checkbox"
bind:group={pendingStates}
name="flavours"
value={state}
/>
{state}
</ListGroupItem>
{/each}
</ListGroup>
</ModalBody>
<ModalFooter>
<Button
color="primary"
disabled={pendingStates.length == 0}
on:click={() => {
isOpen = false;
states = [...pendingStates];
dispatch("set-filter", { states });
}}>Close & Apply</Button
>
<Button
color="danger"
on:click={() => {
isOpen = false;
states = [...allJobStates];
pendingStates = [...allJobStates];
dispatch("set-filter", { states });
}}>Reset</Button
>
<Button on:click={() => (isOpen = false)}>Close</Button>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,259 @@
<!--
@component Filter sub-component for selecting job resources
Properties:
- `cluster Object?`: The currently selected cluster config [Default: null]
- `isOpen Bool?`: Is this filter component opened [Default: false]
- `numNodes Object?`: The currently selected numNodes filter [Default: {from:null, to:null}]
- `numHWThreads Object?`: The currently selected numHWTreads filter [Default: {from:null, to:null}]
- `numAccelerators Object?`: The currently selected numAccelerators filter [Default: {from:null, to:null}]
- `isNodesModified Bool?`: Is the node filter modified [Default: false]
- `isHwtreadsModified Bool?`: Is the Hwthreads filter modified [Default: false]
- `isAccsModified Bool?`: Is the Accelerator filter modified [Default: false]
- `namedNode String?`: The currently selected single named node (= hostname) [Default: null]
Events:
- `set-filter, {Object, Object, Object, String}`: Set 'numNodes, numHWThreads, numAccelerators, namedNode' filter in upstream component
-->
<script>
import { createEventDispatcher, getContext } from "svelte";
import {
Button,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
} from "@sveltestrap/sveltestrap";
import DoubleRangeSlider from "../select/DoubleRangeSlider.svelte";
const clusters = getContext("clusters"),
initialized = getContext("initialized"),
dispatch = createEventDispatcher();
export let cluster = null;
export let isOpen = false;
export let numNodes = { from: null, to: null };
export let numHWThreads = { from: null, to: null };
export let numAccelerators = { from: null, to: null };
export let isNodesModified = false;
export let isHwthreadsModified = false;
export let isAccsModified = false;
export let namedNode = null;
let pendingNumNodes = numNodes,
pendingNumHWThreads = numHWThreads,
pendingNumAccelerators = numAccelerators,
pendingNamedNode = namedNode;
const findMaxNumAccels = (clusters) =>
clusters.reduce(
(max, cluster) =>
Math.max(
max,
cluster.subClusters.reduce(
(max, sc) => Math.max(max, sc.topology.accelerators?.length || 0),
0,
),
),
0,
);
// Limited to Single-Node Thread Count
const findMaxNumHWTreadsPerNode = (clusters) =>
clusters.reduce(
(max, cluster) =>
Math.max(
max,
cluster.subClusters.reduce(
(max, sc) =>
Math.max(
max,
sc.threadsPerCore * sc.coresPerSocket * sc.socketsPerNode || 0,
),
0,
),
),
0,
);
let minNumNodes = 1,
maxNumNodes = 0,
minNumHWThreads = 1,
maxNumHWThreads = 0,
minNumAccelerators = 0,
maxNumAccelerators = 0;
$: {
if ($initialized) {
if (cluster != null) {
const { subClusters } = clusters.find((c) => c.name == cluster);
const { filterRanges } = header.clusters.find((c) => c.name == cluster);
minNumNodes = filterRanges.numNodes.from;
maxNumNodes = filterRanges.numNodes.to;
maxNumAccelerators = findMaxNumAccels([{ subClusters }]);
maxNumHWThreads = findMaxNumHWTreadsPerNode([{ subClusters }]);
} else if (clusters.length > 0) {
const { filterRanges } = header.clusters[0];
minNumNodes = filterRanges.numNodes.from;
maxNumNodes = filterRanges.numNodes.to;
maxNumAccelerators = findMaxNumAccels(clusters);
maxNumHWThreads = findMaxNumHWTreadsPerNode(clusters);
for (let cluster of header.clusters) {
const { filterRanges } = cluster;
minNumNodes = Math.min(minNumNodes, filterRanges.numNodes.from);
maxNumNodes = Math.max(maxNumNodes, filterRanges.numNodes.to);
}
}
}
}
$: {
if (
isOpen &&
$initialized &&
pendingNumNodes.from == null &&
pendingNumNodes.to == null
) {
pendingNumNodes = { from: 0, to: maxNumNodes };
}
}
$: {
if (
isOpen &&
$initialized &&
((pendingNumHWThreads.from == null && pendingNumHWThreads.to == null) ||
isHwthreadsModified == false)
) {
pendingNumHWThreads = { from: 0, to: maxNumHWThreads };
}
}
$: if (maxNumAccelerators != null && maxNumAccelerators > 1) {
if (
isOpen &&
$initialized &&
pendingNumAccelerators.from == null &&
pendingNumAccelerators.to == null
) {
pendingNumAccelerators = { from: 0, to: maxNumAccelerators };
}
}
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>Select number of utilized Resources</ModalHeader>
<ModalBody>
<h6>Named Node</h6>
<input type="text" class="form-control" bind:value={pendingNamedNode} />
<h6 style="margin-top: 1rem;">Number of Nodes</h6>
<DoubleRangeSlider
on:change={({ detail }) => {
pendingNumNodes = { from: detail[0], to: detail[1] };
isNodesModified = true;
}}
min={minNumNodes}
max={maxNumNodes}
firstSlider={pendingNumNodes.from}
secondSlider={pendingNumNodes.to}
inputFieldFrom={pendingNumNodes.from}
inputFieldTo={pendingNumNodes.to}
/>
<h6 style="margin-top: 1rem;">
Number of HWThreads (Use for Single-Node Jobs)
</h6>
<DoubleRangeSlider
on:change={({ detail }) => {
pendingNumHWThreads = { from: detail[0], to: detail[1] };
isHwthreadsModified = true;
}}
min={minNumHWThreads}
max={maxNumHWThreads}
firstSlider={pendingNumHWThreads.from}
secondSlider={pendingNumHWThreads.to}
inputFieldFrom={pendingNumHWThreads.from}
inputFieldTo={pendingNumHWThreads.to}
/>
{#if maxNumAccelerators != null && maxNumAccelerators > 1}
<h6 style="margin-top: 1rem;">Number of Accelerators</h6>
<DoubleRangeSlider
on:change={({ detail }) => {
pendingNumAccelerators = { from: detail[0], to: detail[1] };
isAccsModified = true;
}}
min={minNumAccelerators}
max={maxNumAccelerators}
firstSlider={pendingNumAccelerators.from}
secondSlider={pendingNumAccelerators.to}
inputFieldFrom={pendingNumAccelerators.from}
inputFieldTo={pendingNumAccelerators.to}
/>
{/if}
</ModalBody>
<ModalFooter>
<Button
color="primary"
disabled={pendingNumNodes.from == null || pendingNumNodes.to == null}
on:click={() => {
isOpen = false;
pendingNumNodes = isNodesModified
? pendingNumNodes
: { from: null, to: null };
pendingNumHWThreads = isHwthreadsModified
? pendingNumHWThreads
: { from: null, to: null };
pendingNumAccelerators = isAccsModified
? pendingNumAccelerators
: { from: null, to: null };
numNodes = { from: pendingNumNodes.from, to: pendingNumNodes.to };
numHWThreads = {
from: pendingNumHWThreads.from,
to: pendingNumHWThreads.to,
};
numAccelerators = {
from: pendingNumAccelerators.from,
to: pendingNumAccelerators.to,
};
namedNode = pendingNamedNode;
dispatch("set-filter", {
numNodes,
numHWThreads,
numAccelerators,
namedNode,
});
}}
>
Close & Apply
</Button>
<Button
color="danger"
on:click={() => {
isOpen = false;
pendingNumNodes = { from: null, to: null };
pendingNumHWThreads = { from: null, to: null };
pendingNumAccelerators = { from: null, to: null };
pendingNamedNode = null;
numNodes = { from: pendingNumNodes.from, to: pendingNumNodes.to };
numHWThreads = {
from: pendingNumHWThreads.from,
to: pendingNumHWThreads.to,
};
numAccelerators = {
from: pendingNumAccelerators.from,
to: pendingNumAccelerators.to,
};
isNodesModified = false;
isHwthreadsModified = false;
isAccsModified = false;
namedNode = pendingNamedNode;
dispatch("set-filter", {
numNodes,
numHWThreads,
numAccelerators,
namedNode,
});
}}>Reset</Button
>
<Button on:click={() => (isOpen = false)}>Close</Button>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,134 @@
<!--
@component Filter sub-component for selecting job starttime
Properties:
- `isModified Bool?`: Is this filter component modified [Default: false]
- `isOpen Bool?`: Is this filter component opened [Default: false]
- `from Object?`: The currently selected from startime [Default: null]
- `to Object?`: The currently selected to starttime (i.e. subCluster) [Default: null]
Events:
- `set-filter, {String?, String?}`: Set 'from, to' filter in upstream component
-->
<script>
import { createEventDispatcher } from "svelte";
import { parse, format, sub } from "date-fns";
import {
Row,
Button,
Input,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
FormGroup,
} from "@sveltestrap/sveltestrap";
const dispatch = createEventDispatcher();
export let isModified = false;
export let isOpen = false;
export let from = null;
export let to = null;
let pendingFrom, pendingTo;
const now = new Date(Date.now());
const ago = sub(now, { months: 1 });
const defaultFrom = {
date: format(ago, "yyyy-MM-dd"),
time: format(ago, "HH:mm"),
};
const defaultTo = {
date: format(now, "yyyy-MM-dd"),
time: format(now, "HH:mm"),
};
function reset() {
pendingFrom = from == null ? defaultFrom : fromRFC3339(from);
pendingTo = to == null ? defaultTo : fromRFC3339(to);
}
reset();
function toRFC3339({ date, time }, secs = "00") {
const parsedDate = parse(
date + " " + time + ":" + secs,
"yyyy-MM-dd HH:mm:ss",
new Date(),
);
return parsedDate.toISOString();
}
function fromRFC3339(rfc3339) {
const parsedDate = new Date(rfc3339);
return {
date: format(parsedDate, "yyyy-MM-dd"),
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)}>
<ModalHeader>Select Start Time</ModalHeader>
<ModalBody>
<h4>From</h4>
<Row>
<FormGroup class="col">
<Input type="date" bind:value={pendingFrom.date} />
</FormGroup>
<FormGroup class="col">
<Input type="time" bind:value={pendingFrom.time} />
</FormGroup>
</Row>
<h4>To</h4>
<Row>
<FormGroup class="col">
<Input type="date" bind:value={pendingTo.date} />
</FormGroup>
<FormGroup class="col">
<Input type="time" bind:value={pendingTo.time} />
</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>
<Button
color="danger"
on:click={() => {
isOpen = false;
from = null;
to = null;
reset();
dispatch("set-filter", { from, to });
}}>Reset</Button
>
<Button on:click={() => (isOpen = false)}>Close</Button>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,95 @@
<!--
@component Filter sub-component for selecting job statistics
Properties:
- `isModified Bool?`: Is this filter component modified [Default: false]
- `isOpen Bool?`: Is this filter component opened [Default: false]
- `stats [Object]?`: The currently selected statistics filter [Default: []]
Events:
- `set-filter, {[Object]}`: Set 'stats' filter in upstream component
-->
<script>
import { createEventDispatcher, getContext } from "svelte";
import { getStatsItems } from "../utils.js";
import {
Button,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
} from "@sveltestrap/sveltestrap";
import DoubleRangeSlider from "../select/DoubleRangeSlider.svelte";
const initialized = getContext("initialized"),
dispatch = createEventDispatcher();
export let isModified = false;
export let isOpen = false;
export let stats = [];
let statistics = []
function loadRanges(isInitialized) {
if (!isInitialized) return;
statistics = getStatsItems();
}
function resetRanges() {
for (let st of statistics) {
st.enabled = false
st.from = 0
st.to = st.peak
}
}
$: isModified = !statistics.every((a) => {
let b = stats.find((s) => s.field == a.field);
if (b == null) return !a.enabled;
return a.from == b.from && a.to == b.to;
});
$: loadRanges($initialized);
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>Filter based on statistics (of non-running jobs)</ModalHeader>
<ModalBody>
{#each statistics as stat}
<h4>{stat.text}</h4>
<DoubleRangeSlider
on:change={({ detail }) => (
(stat.from = detail[0]), (stat.to = detail[1]), (stat.enabled = true)
)}
min={0}
max={stat.peak}
firstSlider={stat.from}
secondSlider={stat.to}
inputFieldFrom={stat.from}
inputFieldTo={stat.to}
/>
{/each}
</ModalBody>
<ModalFooter>
<Button
color="primary"
on:click={() => {
isOpen = false;
stats = statistics.filter((stat) => stat.enabled);
dispatch("set-filter", { stats });
}}>Close & Apply</Button
>
<Button
color="danger"
on:click={() => {
isOpen = false;
resetRanges();
stats = [];
dispatch("set-filter", { stats });
}}>Reset</Button
>
<Button on:click={() => (isOpen = false)}>Close</Button>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,101 @@
<!--
@component Filter sub-component for selecting tags
Properties:
- `isModified Bool?`: Is this filter component modified [Default: false]
- `isOpen Bool?`: Is this filter component opened [Default: false]
- `tags [Number]?`: The currently selected tags (as IDs) [Default: []]
Events:
- `set-filter, {[Number]}`: Set 'tag' filter in upstream component
-->
<script>
import { createEventDispatcher, getContext } from "svelte";
import {
Button,
ListGroup,
ListGroupItem,
Input,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
Icon,
} from "@sveltestrap/sveltestrap";
import { fuzzySearchTags } from "../utils.js";
import Tag from "../helper/Tag.svelte";
const allTags = getContext("tags"),
initialized = getContext("initialized"),
dispatch = createEventDispatcher();
export let isModified = false;
export let isOpen = false;
export let tags = [];
let pendingTags = [...tags];
$: isModified =
tags.length != pendingTags.length ||
!tags.every((tagId) => pendingTags.includes(tagId));
let searchTerm = "";
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>Select Tags</ModalHeader>
<ModalBody>
<Input type="text" placeholder="Search" bind:value={searchTerm} />
<br />
<ListGroup>
{#if $initialized}
{#each fuzzySearchTags(searchTerm, allTags) as tag (tag)}
<ListGroupItem>
{#if pendingTags.includes(tag.id)}
<Button
outline
color="danger"
on:click={() =>
(pendingTags = pendingTags.filter((id) => id != tag.id))}
>
<Icon name="dash-circle" />
</Button>
{:else}
<Button
outline
color="success"
on:click={() => (pendingTags = [...pendingTags, tag.id])}
>
<Icon name="plus-circle" />
</Button>
{/if}
<Tag {tag} />
</ListGroupItem>
{:else}
<ListGroupItem disabled>No Tags</ListGroupItem>
{/each}
{/if}
</ListGroup>
</ModalBody>
<ModalFooter>
<Button
color="primary"
on:click={() => {
isOpen = false;
tags = [...pendingTags];
dispatch("set-filter", { tags });
}}>Close & Apply</Button
>
<Button
color="danger"
on:click={() => {
isOpen = false;
tags = [];
pendingTags = [];
dispatch("set-filter", { tags });
}}>Reset</Button
>
<Button on:click={() => (isOpen = false)}>Close</Button>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,228 @@
<!--
@component Footprint component; Displays job.footprint data as bars in relation to thresholds
Properties:
- `job Object`: The GQL job object
- `displayTitle Bool?`: If to display cardHeader with title [Default: true]
- `width String?`: Width of the card [Default: 'auto']
- `height String?`: Height of the card [Default: '310px']
-->
<script context="module">
function findJobThresholds(job, metricConfig) {
if (!job || !metricConfig) {
console.warn("Argument missing for findJobThresholds!");
return null;
}
// metricConfig is on subCluster-Level
const defaultThresholds = {
peak: metricConfig.peak,
normal: metricConfig.normal,
caution: metricConfig.caution,
alert: metricConfig.alert
};
// Job_Exclusivity does not matter, only aggregation
if (metricConfig.aggregation === "avg") {
return defaultThresholds;
} else if (metricConfig.aggregation === "sum") {
const topol = getContext("getHardwareTopology")(job.cluster, job.subCluster)
const jobFraction = job.numHWThreads / topol.node.length;
return {
peak: round(defaultThresholds.peak * jobFraction, 0),
normal: round(defaultThresholds.normal * jobFraction, 0),
caution: round(defaultThresholds.caution * jobFraction, 0),
alert: round(defaultThresholds.alert * jobFraction, 0),
};
} else {
console.warn(
"Missing or unkown aggregation mode (sum/avg) for metric:",
metricConfig,
);
return defaultThresholds;
}
}
</script>
<script>
import { getContext } from "svelte";
import {
Card,
CardHeader,
CardTitle,
CardBody,
Progress,
Icon,
Tooltip,
Row,
Col
} from "@sveltestrap/sveltestrap";
import { round } from "mathjs";
export let job;
export let displayTitle = true;
export let width = "auto";
export let height = "310px";
const footprintData = job?.footprint?.map((jf) => {
// Unit
const fmc = getContext("getMetricConfig")(job.cluster, job.subCluster, jf.name);
const unit = (fmc?.unit?.prefix ? fmc.unit.prefix : "") + (fmc?.unit?.base ? fmc.unit.base : "")
// Threshold / -Differences
const fmt = findJobThresholds(job, fmc);
if (jf.name === "flops_any") fmt.peak = round(fmt.peak * 0.85, 0);
// Define basic data -> Value: Use as Provided
const fmBase = {
name: jf.name + ' (' + jf.stat + ')',
avg: jf.value,
unit: unit,
max: fmt.peak,
dir: fmc.lowerIsBetter
};
if (evalFootprint(jf.value, fmt, fmc.lowerIsBetter, "alert")) {
return {
...fmBase,
color: "danger",
message: `Metric average way ${fmc.lowerIsBetter ? "above" : "below"} expected normal thresholds.`,
impact: 3
};
} else if (evalFootprint(jf.value, fmt, fmc.lowerIsBetter, "caution")) {
return {
...fmBase,
color: "warning",
message: `Metric average ${fmc.lowerIsBetter ? "above" : "below"} expected normal thresholds.`,
impact: 2,
};
} else if (evalFootprint(jf.value, fmt, fmc.lowerIsBetter, "normal")) {
return {
...fmBase,
color: "success",
message: "Metric average within expected thresholds.",
impact: 1,
};
} else if (evalFootprint(jf.value, fmt, fmc.lowerIsBetter, "peak")) {
return {
...fmBase,
color: "info",
message:
"Metric average above expected normal thresholds: Check for artifacts recommended.",
impact: 0,
};
} else {
return {
...fmBase,
color: "secondary",
message:
"Metric average above expected peak threshold: Check for artifacts!",
impact: -1,
};
}
});
function evalFootprint(mean, thresholds, lowerIsBetter, level) {
// Handle Metrics in which less value is better
switch (level) {
case "peak":
if (lowerIsBetter)
return false; // metric over peak -> return false to trigger impact -1
else return mean <= thresholds.peak && mean > thresholds.normal;
case "alert":
if (lowerIsBetter)
return mean <= thresholds.peak && mean >= thresholds.alert;
else return mean <= thresholds.alert && mean >= 0;
case "caution":
if (lowerIsBetter)
return mean < thresholds.alert && mean >= thresholds.caution;
else return mean <= thresholds.caution && mean > thresholds.alert;
case "normal":
if (lowerIsBetter)
return mean < thresholds.caution && mean >= 0;
else return mean <= thresholds.normal && mean > thresholds.caution;
default:
return false;
}
}
</script>
<Card class="mt-1 overflow-auto" style="width: {width}; height: {height}">
{#if displayTitle}
<CardHeader>
<CardTitle class="mb-0 d-flex justify-content-center">
Core Metrics Footprint
</CardTitle>
</CardHeader>
{/if}
<CardBody>
{#each footprintData as fpd, index}
<div class="mb-1 d-flex justify-content-between">
<div>&nbsp;<b>{fpd.name}</b></div>
<!-- For symmetry, see below ...-->
<div
class="cursor-help d-inline-flex"
id={`footprint-${job.jobId}-${index}`}
>
<div class="mx-1">
<!-- Alerts Only -->
{#if fpd.impact === 3 || fpd.impact === -1}
<Icon name="exclamation-triangle-fill" class="text-danger" />
{:else if fpd.impact === 2}
<Icon name="exclamation-triangle" class="text-warning" />
{/if}
<!-- Emoji for all states-->
{#if fpd.impact === 3}
<Icon name="emoji-frown" class="text-danger" />
{:else if fpd.impact === 2}
<Icon name="emoji-neutral" class="text-warning" />
{:else if fpd.impact === 1}
<Icon name="emoji-smile" class="text-success" />
{:else if fpd.impact === 0}
<Icon name="emoji-laughing" class="text-info" />
{:else if fpd.impact === -1}
<Icon name="emoji-dizzy" class="text-danger" />
{/if}
</div>
<div>
<!-- Print Values -->
{fpd.avg} / {fpd.max}
{fpd.unit} &nbsp; <!-- To increase margin to tooltip: No other way manageable ... -->
</div>
</div>
<Tooltip
target={`footprint-${job.jobId}-${index}`}
placement="right"
offset={[0, 20]}>{fpd.message}</Tooltip
>
</div>
<Row cols={12} class="{(footprintData.length == (index + 1)) ? 'mb-0' : 'mb-2'}">
{#if fpd.dir}
<Col xs="1">
<Icon name="caret-left-fill" />
</Col>
{/if}
<Col xs="11" class="align-content-center">
<Progress value={fpd.avg} max={fpd.max} color={fpd.color} />
</Col>
{#if !fpd.dir}
<Col xs="1">
<Icon name="caret-right-fill" />
</Col>
{/if}
</Row>
{/each}
{#if job?.metaData?.message}
<hr class="mt-1 mb-2" />
{@html job.metaData.message}
{/if}
</CardBody>
</Card>
<style>
.cursor-help {
cursor: help;
}
</style>

View File

@@ -0,0 +1,54 @@
<!--
@component Triggers upstream data refresh in selectable intervals
Properties:
- `initially Number?`: Initial refresh interval on component mount, in seconds [Default: null]
Events:
- `refresh`: When fired, the upstream component refreshes its contents
-->
<script>
import { createEventDispatcher } from "svelte";
import { Button, Icon, InputGroup } from "@sveltestrap/sveltestrap";
const dispatch = createEventDispatcher();
let refreshInterval = null;
let refreshIntervalId = null;
function refreshIntervalChanged() {
if (refreshIntervalId != null) clearInterval(refreshIntervalId);
if (refreshInterval == null) return;
refreshIntervalId = setInterval(() => dispatch("refresh"), refreshInterval);
}
export let initially = null;
if (initially != null) {
refreshInterval = initially * 1000;
refreshIntervalChanged();
}
</script>
<InputGroup>
<Button
outline
on:click={() => dispatch("refresh")}
disabled={refreshInterval != null}
>
<Icon name="arrow-clockwise" /> Refresh
</Button>
<select
class="form-select"
bind:value={refreshInterval}
on:change={refreshIntervalChanged}
>
<option value={null}>No periodic refresh</option>
<option value={30 * 1000}>Update every 30 seconds</option>
<option value={60 * 1000}>Update every minute</option>
<option value={2 * 60 * 1000}>Update every two minutes</option>
<option value={5 * 60 * 1000}>Update every 5 minutes</option>
</select>
</InputGroup>

View File

@@ -0,0 +1,44 @@
<!--
@component Single tag pill component
Properties:
- id: ID! (if the tag-id is known but not the tag type/name, this can be used)
- tag: { id: ID!, type: String, name: String }
- clickable: Boolean (default is true)
-->
<script>
import { getContext } from 'svelte'
const allTags = getContext('tags'),
initialized = getContext('initialized')
export let id = null
export let tag = null
export let clickable = true
if (tag != null && id == null)
id = tag.id
$: {
if ($initialized && tag == null)
tag = allTags.find(tag => tag.id == id)
}
</script>
<style>
a {
margin-left: 0.5rem;
line-height: 2;
}
span {
font-size: 0.9rem;
}
</style>
<a target={clickable ? "_blank" : null} href={clickable ? `/monitoring/jobs/?tag=${id}` : null}>
{#if tag}
<span class="badge bg-warning text-dark">{tag.type}: {tag.name}</span>
{:else}
Loading...
{/if}
</a>

View File

@@ -0,0 +1,113 @@
<!--
@component Search Field for Job-Lists with separate mode if project filter is active
Properties:
- `presetProject String?`: Currently active project filter [Default: '']
- `authlevel Number?`: The current users authentication level [Default: null]
- `roles [Number]?`: Enum containing available roles [Default: null]
Events:
- `set-filter, {String?, String?, String?}`: Set 'user, project, jobName' filter in upstream component
-->
<script>
import { InputGroup, Input, Button, Icon } from "@sveltestrap/sveltestrap";
import { createEventDispatcher } from "svelte";
import { scramble, scrambleNames } from "../utils.js";
const dispatch = createEventDispatcher();
export let presetProject = ""; // If page with this component has project preset, keep preset until reset
export let authlevel = null;
export let roles = null;
let mode = presetProject ? "jobName" : "project";
let term = "";
let user = "";
let project = presetProject ? presetProject : "";
let jobName = "";
const throttle = 500;
function modeChanged() {
if (mode == "user") {
project = presetProject ? presetProject : "";
jobName = "";
} else if (mode == "project") {
user = "";
jobName = "";
} else {
project = presetProject ? presetProject : "";
user = "";
}
termChanged(0);
}
let timeoutId = null;
// Compatibility: Handle "user role" and "no role" identically
function termChanged(sleep = throttle) {
if (roles && authlevel >= roles.manager) {
if (mode == "user") user = term;
else if (mode == "project") project = term;
else jobName = term;
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
dispatch("set-filter", {
user,
project,
jobName
});
}, sleep);
} else {
if (mode == "project") project = term;
else jobName = term;
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
dispatch("set-filter", {
project,
jobName
});
}, sleep);
}
}
function resetProject () {
mode = "project"
term = ""
presetProject = ""
project = ""
termChanged(0);
}
</script>
<InputGroup>
<select
style="max-width: 175px;"
class="form-select"
bind:value={mode}
on:change={modeChanged}
>
{#if !presetProject}
<option value={"project"}>Search Project</option>
{/if}
{#if roles && authlevel >= roles.manager}
<option value={"user"}>Search User</option>
{/if}
<option value={"jobName"}>Search Jobname</option>
</select>
<Input
type="text"
bind:value={term}
on:change={() => termChanged()}
on:keyup={(event) => termChanged(event.key == "Enter" ? 0 : throttle)}
placeholder={presetProject ? `Filter ${mode} in ${scrambleNames ? scramble(presetProject) : presetProject} ...` : `Filter ${mode} ...`}
/>
{#if presetProject}
<Button title="Reset Project" on:click={resetProject}
><Icon name="arrow-counterclockwise" /></Button
>
{/if}
</InputGroup>

View File

@@ -0,0 +1,133 @@
<!--
@component Displays job metaData, serves links to detail pages
Properties:
- `job Object`: The Job Object (GraphQL.Job)
- `jobTags [Number]?`: The jobs tags as IDs, default useful for dynamically updating the tags [Default: job.tags]
-->
<script>
import { Badge, Icon } from "@sveltestrap/sveltestrap";
import { scrambleNames, scramble } from "../utils.js";
import Tag from "../helper/Tag.svelte";
export let job;
export let jobTags = job.tags;
function formatDuration(duration) {
const hours = Math.floor(duration / 3600);
duration -= hours * 3600;
const minutes = Math.floor(duration / 60);
duration -= minutes * 60;
const seconds = duration;
return `${hours}:${("0" + minutes).slice(-2)}:${("0" + seconds).slice(-2)}`;
}
function getStateColor(state) {
switch (state) {
case "running":
return "success";
case "completed":
return "primary";
default:
return "danger";
}
}
</script>
<div>
<p>
<span class="fw-bold"
><a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a>
({job.cluster})</span
>
{#if job.metaData?.jobName}
<br />
{#if job.metaData?.jobName.length <= 25}
<div>{job.metaData.jobName}</div>
{:else}
<div
class="truncate"
style="cursor:help; width:230px;"
title={job.metaData.jobName}
>
{job.metaData.jobName}
</div>
{/if}
{/if}
{#if job.arrayJobId}
Array Job: <a
href="/monitoring/jobs/?arrayJobId={job.arrayJobId}&cluster={job.cluster}"
target="_blank">#{job.arrayJobId}</a
>
{/if}
</p>
<p>
<Icon name="person-fill" />
<a class="fst-italic" href="/monitoring/user/{job.user}" target="_blank">
{scrambleNames ? scramble(job.user) : job.user}
</a>
{#if job.userData && job.userData.name}
({scrambleNames ? scramble(job.userData.name) : job.userData.name})
{/if}
{#if job.project && job.project != "no project"}
<br />
<Icon name="people-fill" />
<a
class="fst-italic"
href="/monitoring/jobs/?project={job.project}&projectMatch=eq"
target="_blank"
>
{scrambleNames ? scramble(job.project) : job.project}
</a>
{/if}
</p>
<p>
{#if job.numNodes == 1}
{job.resources[0].hostname}
{:else}
{job.numNodes}
{/if}
<Icon name="pc-horizontal" />
{#if job.exclusive != 1}
(shared)
{/if}
{#if job.numAcc > 0}
, {job.numAcc} <Icon name="gpu-card" />
{/if}
{#if job.numHWThreads > 0}
, {job.numHWThreads} <Icon name="cpu" />
{/if}
<br />
{job.subCluster}
</p>
<p>
Start: <span class="fw-bold"
>{new Date(job.startTime).toLocaleString()}</span
>
<br />
Duration: <span class="fw-bold">{formatDuration(job.duration)}</span>
<Badge color={getStateColor(job.state)}>{job.state}</Badge>
{#if job.walltime}
<br />
Walltime: <span class="fw-bold">{formatDuration(job.walltime)}</span>
{/if}
</p>
<p>
{#each jobTags as tag}
<Tag {tag} />
{/each}
</p>
</div>
<style>
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,188 @@
<!--
@component Data row for a single job displaying metric plots
Properties:
- `job Object`: The job object (GraphQL.Job)
- `metrics [String]`: Currently selected metrics
- `plotWidth Number`: Width of the sub-components
- `plotHeight Number?`: Height of the sub-components [Default: 275]
- `showFootprint Bool`: Display of footprint component for job
- `triggerMetricRefresh Bool?`: If changed to true from upstream, will trigger metric query
-->
<script>
import { queryStore, gql, getContextClient } from "@urql/svelte";
import { getContext } from "svelte";
import { Card, Spinner } from "@sveltestrap/sveltestrap";
import { maxScope, checkMetricDisabled } from "../utils.js";
import JobInfo from "./JobInfo.svelte";
import MetricPlot from "../plots/MetricPlot.svelte";
import JobFootprint from "../helper/JobFootprint.svelte";
export let job;
export let metrics;
export let plotWidth;
export let plotHeight = 275;
export let showFootprint;
export let triggerMetricRefresh = false;
let { id } = job;
let scopes = job.numNodes == 1
? job.numAcc >= 1
? ["core", "accelerator"]
: ["core"]
: ["node"];
const cluster = getContext("clusters").find((c) => c.name == job.cluster);
const client = getContextClient();
const query = gql`
query ($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!) {
jobMetrics(id: $id, metrics: $metrics, scopes: $scopes) {
name
scope
metric {
unit {
prefix
base
}
timestep
statisticsSeries {
min
median
max
}
series {
hostname
id
data
statistics {
min
avg
max
}
}
}
}
}
`;
$: metricsQuery = queryStore({
client: client,
query: query,
variables: { id, metrics, scopes },
});
function refreshMetrics() {
metricsQuery = queryStore({
client: client,
query: query,
variables: { id, metrics, scopes },
// requestPolicy: 'network-only' // use default cache-first for refresh
});
}
$: if (job.state === 'running' && triggerMetricRefresh === true) {
refreshMetrics();
}
// Helper
const selectScope = (jobMetrics) =>
jobMetrics.reduce(
(a, b) =>
maxScope([a.scope, b.scope]) == a.scope
? job.numNodes > 1
? a
: b
: job.numNodes > 1
? b
: a,
jobMetrics[0],
);
const sortAndSelectScope = (jobMetrics) =>
metrics
.map((name) => jobMetrics.filter((jobMetric) => jobMetric.name == name))
.map((jobMetrics) => ({
disabled: false,
data: jobMetrics.length > 0 ? selectScope(jobMetrics) : null,
}))
.map((jobMetric) => {
if (jobMetric.data) {
return {
disabled: checkMetricDisabled(
jobMetric.data.name,
job.cluster,
job.subCluster,
),
data: jobMetric.data,
};
} else {
return jobMetric;
}
});
</script>
<tr>
<td>
<JobInfo {job} />
</td>
{#if job.monitoringStatus == 0 || job.monitoringStatus == 2}
<td colspan={metrics.length}>
<Card body color="warning">Not monitored or archiving failed</Card>
</td>
{:else if $metricsQuery.fetching}
<td colspan={metrics.length} style="text-align: center;">
<Spinner secondary />
</td>
{:else if $metricsQuery.error}
<td colspan={metrics.length}>
<Card body color="danger" class="mb-3">
{$metricsQuery.error.message.length > 500
? $metricsQuery.error.message.substring(0, 499) + "..."
: $metricsQuery.error.message}
</Card>
</td>
{:else}
{#if showFootprint}
<td>
<JobFootprint
{job}
width={plotWidth}
height="{plotHeight}px"
displayTitle={false}
/>
</td>
{/if}
{#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric || i)}
<td>
<!-- Subluster Metricconfig remove keyword for jobtables (joblist main, user joblist, project joblist) to be used here as toplevel case-->
{#if metric.disabled == false && metric.data}
<MetricPlot
width={plotWidth}
height={plotHeight}
timestep={metric.data.metric.timestep}
scope={metric.data.scope}
series={metric.data.metric.series}
statisticsSeries={metric.data.metric.statisticsSeries}
metric={metric.data.name}
{cluster}
subCluster={job.subCluster}
isShared={job.exclusive != 1}
resources={job.resources}
numhwthreads={job.numHWThreads}
numaccs={job.numAcc}
/>
{:else if metric.disabled == true && metric.data}
<Card body color="info"
>Metric disabled for subcluster <code
>{metric.data.name}:{job.subCluster}</code
></Card
>
{:else}
<Card body color="warning">No dataset returned</Card>
{/if}
</td>
{/each}
{/if}
</tr>

View File

@@ -0,0 +1,231 @@
<!--
@component Pagination selection component
Properties:
- page: Number (changes from inside)
- itemsPerPage: Number (changes from inside)
- totalItems: Number (only displayed)
Events:
- "update-paging": { page: Number, itemsPerPage: Number }
- Dispatched once immediately and then each time page or itemsPerPage changes
-->
<div class="cc-pagination" >
<div class="cc-pagination-left">
<label for="cc-pagination-select">{ itemText } per page:</label>
<div class="cc-pagination-select-wrapper">
<select on:blur|preventDefault={reset} bind:value={itemsPerPage} id="cc-pagination-select" class="cc-pagination-select">
{#each pageSizes as size}
<option value="{size}">{size}</option>
{/each}
</select>
<span class="focus"></span>
</div>
<span class="cc-pagination-text">
{ (page - 1) * itemsPerPage } - { Math.min((page - 1) * itemsPerPage + itemsPerPage, totalItems) } of { totalItems } { itemText }
</span>
</div>
<div class="cc-pagination-right">
{#if !backButtonDisabled}
<button class="reset nav" type="button"
on:click|preventDefault="{reset}"></button>
<button class="left nav" type="button"
on:click|preventDefault="{() => { page -= 1; }}"></button>
{/if}
{#if !nextButtonDisabled}
<button class="right nav" type="button"
on:click|preventDefault="{() => { page += 1; }}"></button>
{/if}
</div>
</div>
<script>
import { createEventDispatcher } from "svelte";
export let page = 1;
export let itemsPerPage = 10;
export let totalItems = 0;
export let itemText = "items";
export let pageSizes = [10,25,50];
let backButtonDisabled, nextButtonDisabled;
const dispatch = createEventDispatcher();
$: {
if (typeof page !== "number") {
page = Number(page);
}
if (typeof itemsPerPage !== "number") {
itemsPerPage = Number(itemsPerPage);
}
dispatch("update-paging", { itemsPerPage, page });
}
$: backButtonDisabled = (page === 1);
$: nextButtonDisabled = (page >= (totalItems / itemsPerPage));
function reset ( event ) {
page = 1;
}
</script>
<style>
*, *::before, *::after {
box-sizing: border-box;
}
div {
display: flex;
align-items: center;
vertical-align: baseline;
box-sizing: border-box;
}
label, select, button {
margin: 0;
padding: 0;
vertical-align: baseline;
color: #525252;
}
button {
position: relative;
border: none;
border-left: 1px solid #e0e0e0;
height: 3rem;
width: 3rem;
background: 0 0;
transition: all 70ms;
}
button:hover {
background-color: #dde1e6;
}
button:focus {
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border: 1px solid blue;
border-radius: inherit;
}
.nav::after {
content: "";
width: 0.9em;
height: 0.8em;
background-color: #777;
z-index: 1;
position: absolute;
top: 50%;
left: 50%;
}
.nav:disabled {
background-color: #fff;
cursor: no-drop;
}
.reset::after {
clip-path: polygon(100% 0%, 75% 50%, 100% 100%, 25% 100%, 0% 50%, 25% 0%);
margin-top: -0.3em;
margin-left: -0.5em;
}
.right::after {
clip-path: polygon(100% 50%, 50% 0, 50% 100%);
margin-top: -0.3em;
margin-left: -0.5em;
}
.left::after {
clip-path: polygon(50% 0, 0 50%, 50% 100%);
margin-top: -0.3em;
margin-left: -0.3em;
}
.cc-pagination-select-wrapper::after {
content: "";
width: 0.8em;
height: 0.5em;
background-color: #777;
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
justify-self: end;
}
.cc-pagination {
width: 100%;
justify-content: space-between;
border-top: 1px solid #e0e0e0;
}
.cc-pagination-text {
color: #525252;
margin-left: 1rem;
}
.cc-pagination-text {
color: #525252;
margin-right: 1rem;
}
.cc-pagination-left {
padding: 0 1rem;
height: 3rem;
}
.cc-pagination-select-wrapper {
display: grid;
grid-template-areas: "select";
align-items: center;
position: relative;
padding: 0 0.5em;
min-width: 3em;
max-width: 6em;
border-right: 1px solid #e0e0e0;
cursor: pointer;
transition: all 70ms;
}
.cc-pagination-select-wrapper:hover {
background-color: #dde1e6;
}
select,
.cc-pagination-select-wrapper::after {
grid-area: select;
}
.cc-pagination-select {
height: 3rem;
appearance: none;
background-color: transparent;
padding: 0 1em 0 0;
margin: 0;
border: none;
width: 100%;
font-family: inherit;
font-size: inherit;
cursor: inherit;
line-height: inherit;
z-index: 1;
outline: none;
}
select:focus + .focus {
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border: 1px solid blue;
border-radius: inherit;
}
.cc-pagination-right {
height: 3rem;
}
</style>

View File

@@ -0,0 +1,235 @@
<!--
@component Histogram Plot based on uPlot Bars
Properties:
- `data [[],[]]`: uPlot data structure array ( [[],[]] == [X, Y] )
- `usesBins Bool?`: If X-Axis labels are bins ("XX-YY") [Default: false]
- `width Number?`: Plot width (reactively adaptive) [Default: 500]
- `height Number?`: Plot height (reactively adaptive) [Default: 300]
- `title String?`: Plot title [Default: ""]
- `xlabel String?`: Plot X axis label [Default: ""]
- `xunit String?`: Plot X axis unit [Default: ""]
- `ylabel String?`: Plot Y axis label [Default: ""]
- `yunit String?`: Plot Y axis unit [Default: ""]
-->
<script>
import uPlot from "uplot";
import { formatNumber } from "../units.js";
import { onMount, onDestroy } from "svelte";
import { Card } from "@sveltestrap/sveltestrap";
export let data;
export let usesBins = false;
export let width = 500;
export let height = 300;
export let title = "";
export let xlabel = "";
export let xunit = "";
export let ylabel = "";
export let yunit = "";
const { bars } = uPlot.paths;
const drawStyles = {
bars: 1,
points: 2,
};
function paths(u, seriesIdx, idx0, idx1, extendGap, buildClip) {
let s = u.series[seriesIdx];
let style = s.drawStyle;
let renderer = // If bars to wide, change here
style == drawStyles.bars ? bars({ size: [0.75, 100] }) : () => null;
return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip);
}
// 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, {
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";
});
// let tooltip exit plot
// overEl.style.overflow = "visible";
}
function update(u) {
const { left, top } = u.cursor;
legendEl.style.transform =
"translate(" + (left + 15) + "px, " + (top + 15) + "px)";
}
return {
hooks: {
init: init,
setCursor: update,
},
};
}
let plotWrapper = null;
let uplot = null;
let timeoutId = null;
function render() {
let opts = {
width: width,
height: height,
title: title,
plugins: [legendAsTooltipPlugin()],
cursor: {
points: {
size: (u, seriesIdx) => u.series[seriesIdx].points.size * 2.5,
width: (u, seriesIdx, size) => size / 4,
stroke: (u, seriesIdx) =>
u.series[seriesIdx].points.stroke(u, seriesIdx) + "90",
fill: (u, seriesIdx) => "#fff",
},
},
scales: {
x: {
time: false,
},
},
axes: [
{
stroke: "#000000",
// scale: 'x',
label: xlabel,
labelGap: 10,
size: 25,
incrs: [1, 2, 5, 6, 10, 12, 50, 100, 500, 1000, 5000, 10000],
border: {
show: true,
stroke: "#000000",
},
ticks: {
width: 1 / devicePixelRatio,
size: 5 / devicePixelRatio,
stroke: "#000000",
},
values: (_, t) => t.map((v) => formatNumber(v)),
},
{
stroke: "#000000",
// scale: 'y',
label: ylabel,
labelGap: 10,
size: 35,
border: {
show: true,
stroke: "#000000",
},
ticks: {
width: 1 / devicePixelRatio,
size: 5 / devicePixelRatio,
stroke: "#000000",
},
values: (_, t) => t.map((v) => formatNumber(v)),
},
],
series: [
{
label: xunit !== "" ? xunit : null,
value: (u, ts, sidx, didx) => {
if (usesBins) {
const min = u.data[sidx][didx - 1] ? u.data[sidx][didx - 1] : 0;
const max = u.data[sidx][didx];
ts = min + "-" + max; // narrow spaces
}
return ts;
},
},
Object.assign(
{
label: yunit !== "" ? yunit : null,
width: 1 / devicePixelRatio,
drawStyle: drawStyles.points,
lineInterpolation: null,
paths,
},
{
drawStyle: drawStyles.bars,
lineInterpolation: null,
stroke: "#85abce",
fill: "#85abce", // + "1A", // Transparent Fill
},
),
],
};
uplot = new uPlot(opts, data, plotWrapper);
}
onMount(() => {
render();
});
onDestroy(() => {
if (uplot) uplot.destroy();
if (timeoutId != null) clearTimeout(timeoutId);
});
function sizeChanged() {
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
if (uplot) uplot.destroy();
render();
}, 200);
}
$: sizeChanged(width, height);
</script>
{#if data.length > 0}
<div bind:this={plotWrapper} />
{:else}
<Card class="mx-4" body color="warning"
>Cannot render histogram: No data!</Card
>
{/if}

View File

@@ -0,0 +1,516 @@
<!--
@component Main plot component, based on uPlot; metricdata values by time
Only width/height should change reactively.
Properties:
- `metric String`: The metric name
- `scope String?`: Scope of the displayed data [Default: node]
- `resources [GraphQL.Resource]`: List of resources used for parent job
- `width Number`: The plot width
- `height Number`: The plot height
- `timestep Number`: The timestep used for X-axis rendering
- `series [GraphQL.Series]`: The metric data object
- `useStatsSeries Bool?`: If this plot uses the statistics Min/Max/Median representation; automatically set to according bool [Default: null]
- `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null]
- `cluster GraphQL.Cluster`: Cluster Object of the parent job
- `subCluster String`: Name of the subCluster of the parent job
- `isShared Bool?`: If this job used shared resources; will adapt threshold indicators accordingly [Default: false]
- `forNode Bool?`: If this plot is used for node data display; will render x-axis as negative time with $now as maximum [Default: false]
- `numhwthreads Number?`: Number of job HWThreads [Default: 0]
- `numaccs Number?`: Number of job Accelerators [Default: 0]
-->
<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, 300, 900, 1800, 3600, 7200, 14400, 21600]; // forNode fixed increments
} else {
let incrs = [];
for (let t = timestep; t < maxX; t *= 10)
incrs.push(t, t * 2, t * 3, t * 5);
return incrs;
}
}
// removed arg "subcluster": input metricconfig and topology now directly derived from subcluster
function findThresholds(
subClusterTopology,
metricConfig,
scope,
isShared,
numhwthreads,
numaccs
) {
if (!subClusterTopology || !metricConfig || !scope) {
console.warn("Argument missing for findThresholds!");
return null;
}
if (
(scope == "node" && isShared == false) ||
metricConfig?.aggregation == "avg"
) {
return {
normal: metricConfig.normal,
caution: metricConfig.caution,
alert: metricConfig.alert,
peak: metricConfig.peak,
};
}
if (metricConfig?.aggregation == "sum") {
let divisor = 1
if (isShared == true) { // Shared
if (numaccs > 0) divisor = subClusterTopology.accelerators.length / numaccs;
else if (numhwthreads > 0) divisor = subClusterTopology.node.length / numhwthreads;
}
else if (scope == 'socket') divisor = subClusterTopology.socket.length;
else if (scope == "core") divisor = subClusterTopology.core.length;
else if (scope == "accelerator")
divisor = subClusterTopology.accelerators.length;
else if (scope == "hwthread") divisor = subClusterTopology.node.length;
else {
// console.log('TODO: how to calc thresholds for ', scope)
return null;
}
return {
peak: metricConfig.peak / divisor,
normal: metricConfig.normal / divisor,
caution: metricConfig.caution / divisor,
alert: metricConfig.alert / divisor,
};
}
console.warn(
"Missing or unkown aggregation mode (sum/avg) for metric:",
metricConfig,
);
return null;
}
</script>
<script>
import uPlot from "uplot";
import { formatNumber } from "../units.js";
import { getContext, onMount, onDestroy } from "svelte";
import { Card } from "@sveltestrap/sveltestrap";
export let metric;
export let scope = "node";
export let resources = [];
export let width;
export let height;
export let timestep;
export let series;
export let useStatsSeries = null;
export let statisticsSeries = null;
export let cluster;
export let subCluster;
export let isShared = false;
export let forNode = false;
export let numhwthreads = 0;
export let numaccs = 0;
if (useStatsSeries == null) useStatsSeries = statisticsSeries != null;
if (useStatsSeries == false && series == null) useStatsSeries = true;
const subClusterTopology = getContext("getHardwareTopology")(cluster, subCluster);
const metricConfig = getContext("getMetricConfig")(cluster, subCluster, metric);
const clusterCockpitConfig = getContext("cc-config");
const resizeSleepTime = 250;
const normalLineColor = "#000000";
const lineWidth =
clusterCockpitConfig.plot_general_lineWidth / window.devicePixelRatio;
const lineColors = clusterCockpitConfig.plot_general_colorscheme;
const backgroundColors = {
normal: "rgba(255, 255, 255, 1.0)",
caution: "rgba(255, 128, 0, 0.3)",
alert: "rgba(255, 0, 0, 0.3)",
};
const thresholds = findThresholds(
subClusterTopology,
metricConfig,
scope,
isShared,
numhwthreads,
numaccs
);
// converts the legend into a simple tooltip
function legendAsTooltipPlugin({
className,
style = { backgroundColor: "rgba(255, 249, 196, 0.92)", color: "black" },
} = {}) {
let legendEl;
const dataSize = series.length;
function init(u, opts) {
legendEl = u.root.querySelector(".u-legend");
legendEl.classList.remove("u-inline");
className && legendEl.classList.add(className);
uPlot.assign(legendEl.style, {
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,
});
// conditional hide series color markers:
if (
useStatsSeries === true || // Min/Max/Median Self-Explanatory
dataSize === 1 || // Only one Y-Dataseries
dataSize > 6
) {
// More than 6 Y-Dataseries
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";
});
// let tooltip exit plot
// overEl.style.overflow = "visible";
}
function update(u) {
const { left, top } = u.cursor;
const width = u.over.querySelector(".u-legend").offsetWidth;
legendEl.style.transform =
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)";
}
if (dataSize <= 12 || useStatsSeries === true) {
return {
hooks: {
init: init,
setCursor: update,
},
};
} else {
// Setting legend-opts show/live as object with false here will not work ...
return {};
}
}
function backgroundColor() {
if (
clusterCockpitConfig.plot_general_colorBackground == false ||
!thresholds ||
!(series && series.every((s) => s.statistics != null))
)
return backgroundColors.normal;
let cond =
thresholds.alert < thresholds.caution
? (a, b) => a <= b
: (a, b) => a >= b;
let avg =
series.reduce((sum, series) => sum + series.statistics.avg, 0) /
series.length;
if (Number.isNaN(avg)) return backgroundColors.normal;
if (cond(avg, thresholds.alert)) return backgroundColors.alert;
if (cond(avg, thresholds.caution)) return backgroundColors.caution;
return backgroundColors.normal;
}
function lineColor(i, n) {
if (n >= lineColors.length) return lineColors[i % lineColors.length];
else return lineColors[Math.floor((i / n) * lineColors.length)];
}
const longestSeries = useStatsSeries
? statisticsSeries.median.length
: series.reduce((n, series) => Math.max(n, series.data.length), 0);
const maxX = longestSeries * timestep;
let maxY = null;
if (thresholds !== null) {
maxY = useStatsSeries
? statisticsSeries.max.reduce(
(max, x) => Math.max(max, x),
thresholds.normal,
) || thresholds.normal
: series.reduce(
(max, series) => Math.max(max, series.statistics?.max),
thresholds.normal,
) || thresholds.normal;
if (maxY >= 10 * thresholds.peak) {
// Hard y-range render limit if outliers in series data
maxY = 10 * thresholds.peak;
}
}
const plotSeries = [
{
label: "Runtime",
value: (u, ts, sidx, didx) =>
didx == null ? null : formatTime(ts, forNode),
},
];
const plotData = [new Array(longestSeries)];
if (forNode === true) {
// Negative Timestamp Buildup
for (let i = 0; i <= longestSeries; i++) {
plotData[0][i] = (longestSeries - i) * timestep * -1;
}
} else {
// Positive Timestamp Buildup
for (
let j = 0;
j < longestSeries;
j++ // TODO: Cache/Reuse this array?
)
plotData[0][j] = j * timestep;
}
let plotBands = undefined;
if (useStatsSeries) {
plotData.push(statisticsSeries.min);
plotData.push(statisticsSeries.max);
plotData.push(statisticsSeries.median);
// plotData.push(statisticsSeries.mean);
if (forNode === true) {
// timestamp 0 with null value for reversed time axis
if (plotData[1].length != 0) plotData[1].push(null);
if (plotData[2].length != 0) plotData[2].push(null);
if (plotData[3].length != 0) plotData[3].push(null);
// if (plotData[4].length != 0) plotData[4].push(null);
}
plotSeries.push({
label: "min",
scale: "y",
width: lineWidth,
stroke: "red",
});
plotSeries.push({
label: "max",
scale: "y",
width: lineWidth,
stroke: "green",
});
plotSeries.push({
label: "median",
scale: "y",
width: lineWidth,
stroke: "black",
});
// plotSeries.push({
// label: "mean",
// scale: "y",
// width: lineWidth,
// stroke: "blue",
// });
plotBands = [
{ series: [2, 3], fill: "rgba(0,255,0,0.1)" },
{ series: [3, 1], fill: "rgba(255,0,0,0.1)" },
];
} else {
for (let i = 0; i < series.length; i++) {
plotData.push(series[i].data);
if (forNode === true && plotData[1].length != 0) plotData[1].push(null); // timestamp 0 with null value for reversed time axis
plotSeries.push({
label:
scope === "node"
? resources[i].hostname
: // scope === 'accelerator' ? resources[0].accelerators[i] :
scope + " #" + (i + 1),
scale: "y",
width: lineWidth,
stroke: lineColor(i, series.length),
});
}
}
const opts = {
width,
height,
plugins: [legendAsTooltipPlugin()],
series: plotSeries,
axes: [
{
scale: "x",
space: 35,
incrs: timeIncrs(timestep, maxX, forNode),
values: (_, vals) => vals.map((v) => formatTime(v, forNode)),
},
{
scale: "y",
grid: { show: true },
labelFont: "sans-serif",
values: (u, vals) => vals.map((v) => formatNumber(v)),
},
],
bands: plotBands,
padding: [5, 10, -20, 0],
hooks: {
draw: [
(u) => {
// Draw plot type label:
let textl = `${scope}${plotSeries.length > 2 ? "s" : ""}${
useStatsSeries
? ": min/median/max"
: metricConfig != null && scope != metricConfig.scope
? ` (${metricConfig.aggregation})`
: ""
}`;
let textr = `${isShared && scope != "core" && scope != "accelerator" ? "[Shared]" : ""}`;
u.ctx.save();
u.ctx.textAlign = "start"; // 'end'
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.fillText(text, u.bbox.left + u.bbox.width - 10, u.bbox.top + u.bbox.height - 10) // Recipe for bottom right
if (!thresholds) {
u.ctx.restore();
return;
}
let y = u.valToPos(thresholds.normal, "y", true);
u.ctx.save();
u.ctx.lineWidth = lineWidth;
u.ctx.strokeStyle = normalLineColor;
u.ctx.setLineDash([5, 5]);
u.ctx.beginPath();
u.ctx.moveTo(u.bbox.left, y);
u.ctx.lineTo(u.bbox.left + u.bbox.width, y);
u.ctx.stroke();
u.ctx.restore();
},
],
},
scales: {
x: { time: false },
y: maxY ? { min: 0, max: (maxY * 1.1) } : {auto: true}, // Add some space to upper render limit
},
legend: {
// Display legend until max 12 Y-dataseries
show: series.length <= 12 || useStatsSeries === true ? true : false,
live: series.length <= 12 || useStatsSeries === true ? true : false,
},
cursor: { drag: { x: true, y: true } },
};
let plotWrapper = null;
let uplot = null;
let timeoutId = null;
let prevWidth = null,
prevHeight = null;
function render() {
if (!width || Number.isNaN(width) || width < 0) return;
if (prevWidth != null && Math.abs(prevWidth - width) < 10) return;
prevWidth = width;
prevHeight = height;
if (!uplot) {
opts.width = width;
opts.height = height;
uplot = new uPlot(opts, plotData, plotWrapper);
} else {
uplot.setSize({ width, height });
}
}
function onSizeChange() {
if (!uplot) return;
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
render();
}, resizeSleepTime);
}
$: if (series[0].data.length > 0) {
onSizeChange(width, height);
}
onMount(() => {
if (series[0].data.length > 0) {
plotWrapper.style.backgroundColor = backgroundColor();
render();
}
});
onDestroy(() => {
if (uplot) uplot.destroy();
if (timeoutId != null) clearTimeout(timeoutId);
});
</script>
{#if series[0].data.length > 0}
<div bind:this={plotWrapper} class="cc-plot"></div>
{:else}
<Card class="mx-4" body color="warning"
>Cannot render plot: No series data returned for <code>{metric}</code></Card
>
{/if}
<style>
.cc-plot {
border-radius: 5px;
}
</style>

View File

@@ -0,0 +1,95 @@
<!--
@component Pie Plot based on uPlot Pie
Properties:
- `size Number`: X and Y size of the plot, for square shape
- `sliceLabel String`: Label used in segment legends
- `quantities [Number]`: Data values
- `entities [String]`: Data identifiers
- `displayLegend?`: Display uPlot legend [Default: false]
Exported:
- `colors ['rgb(x,y,z)', ...]`: Color range used for segments; upstream used for legend
-->
<script context="module">
// http://tsitsul.in/blog/coloropt/ : 12 colors normal
export const colors = [
'rgb(235,172,35)',
'rgb(184,0,88)',
'rgb(0,140,249)',
'rgb(0,110,0)',
'rgb(0,187,173)',
'rgb(209,99,230)',
'rgb(178,69,2)',
'rgb(255,146,135)',
'rgb(89,84,214)',
'rgb(0,198,248)',
'rgb(135,133,0)',
'rgb(0,167,108)',
'rgb(189,189,189)'
]
</script>
<script>
import { Pie } from 'svelte-chartjs';
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
Filler,
ArcElement,
CategoryScale
} from 'chart.js';
ChartJS.register(
Title,
Tooltip,
Legend,
Filler,
ArcElement,
CategoryScale
);
export let size
export let sliceLabel
export let quantities
export let entities
export let displayLegend = false
$: data = {
labels: entities,
datasets: [
{
label: sliceLabel,
data: quantities,
fill: 1,
backgroundColor: colors.slice(0, quantities.length)
}
]
}
const options = {
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
display: displayLegend
}
}
}
</script>
<div class="chart-container" style="--container-width: {size}; --container-height: {size}">
<Pie {data} {options}/>
</div>
<style>
.chart-container {
position: relative;
margin: auto;
height: var(--container-height);
width: var(--container-width);
}
</style>

View File

@@ -0,0 +1,124 @@
<!--
@component Polar Plot based on chartJS Radar
Properties:
- `metrics [String]`: Metric names to display as polar plot
- `cluster GraphQL.Cluster`: Cluster Object of the parent job
- `subCluster GraphQL.SubCluster`: SubCluster Object of the parent job
- `jobMetrics [GraphQL.JobMetricWithName]`: Metric data
- `height Number?`: Plot height [Default: 365]
-->
<script>
import { getContext } from 'svelte'
import { Radar } from 'svelte-chartjs';
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
Filler,
PointElement,
RadialLinearScale,
LineElement
} from 'chart.js';
ChartJS.register(
Title,
Tooltip,
Legend,
Filler,
PointElement,
RadialLinearScale,
LineElement
);
export let metrics
export let cluster
export let subCluster
export let jobMetrics
export let height = 365
const getMetricConfig = getContext("getMetricConfig")
const labels = metrics.filter(name => {
if (!jobMetrics.find(m => m.name == name && m.scope == "node")) {
console.warn(`PolarPlot: No metric data for '${name}'`)
return false
}
return true
})
const getValuesForStat = (getStat) => labels.map(name => {
const peak = getMetricConfig(cluster, subCluster, name).peak
const metric = jobMetrics.find(m => m.name == name && m.scope == "node")
const value = getStat(metric.metric) / peak
return value <= 1. ? value : 1.
})
function getMax(metric) {
let max = 0
for (let series of metric.series)
max = Math.max(max, series.statistics.max)
return max
}
function getAvg(metric) {
let avg = 0
for (let series of metric.series)
avg += series.statistics.avg
return avg / metric.series.length
}
const data = {
labels: labels,
datasets: [
{
label: 'Max',
data: getValuesForStat(getMax),
fill: 1,
backgroundColor: 'rgba(0, 102, 255, 0.25)',
borderColor: 'rgb(0, 102, 255)',
pointBackgroundColor: 'rgb(0, 102, 255)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgb(0, 102, 255)'
},
{
label: 'Avg',
data: getValuesForStat(getAvg),
fill: true,
backgroundColor: 'rgba(255, 153, 0, 0.25)',
borderColor: 'rgb(255, 153, 0)',
pointBackgroundColor: 'rgb(255, 153, 0)',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: 'rgb(255, 153, 0)'
}
]
}
// No custom defined options but keep for clarity
const options = {
maintainAspectRatio: false,
animation: false,
scales: { // fix scale
r: {
suggestedMin: 0.0,
suggestedMax: 1.0
}
}
}
</script>
<div class="chart-container">
<Radar {data} {options} {height}/>
</div>
<style>
.chart-container {
margin: auto;
position: relative;
}
</style>

View File

@@ -0,0 +1,372 @@
<!--
@component Roofline Model Plot based on uPlot
Properties:
- `data [null, [], []]`: Roofline Data Structure, see below for details [Default: null]
- `renderTime Bool?`: If time information should be rendered as colored dots [Default: false]
- `allowSizeChange Bool?`: If dimensions of rendered plot can change [Default: false]
- `subCluster GraphQL.SubCluster?`: SubCluster Object; contains required topology information [Default: null]
- `width Number?`: Plot width (reactively adaptive) [Default: 600]
- `height Number?`: Plot height (reactively adaptive) [Default: 350]
Data Format:
- `data = [null, [], []]`
- Index 0: null-axis required for scatter
- Index 1: Array of XY-Arrays for Scatter
- Index 2: Optional Time Info
- `data[1][0] = [100, 200, 500, ...]`
- X Axis: Intensity (Vals up to clusters' flopRateScalar value)
- `data[1][1] = [1000, 2000, 1500, ...]`
- Y Axis: Performance (Vals up to clusters' flopRateSimd value)
- `data[2] = [0.1, 0.15, 0.2, ...]`
- Color Code: Time Information (Floats from 0 to 1) (Optional)
-->
<script>
import uPlot from "uplot";
import { formatNumber } from "../units.js";
import { onMount, onDestroy } from "svelte";
import { Card } from "@sveltestrap/sveltestrap";
export let data = null;
export let renderTime = false;
export let allowSizeChange = false;
export let subCluster = null;
export let width = 600;
export let height = 350;
let plotWrapper = null;
let uplot = null;
let timeoutId = null;
const lineWidth = clusterCockpitConfig.plot_general_lineWidth;
// Helpers
function getGradientR(x) {
if (x < 0.5) return 0;
if (x > 0.75) return 255;
x = (x - 0.5) * 4.0;
return Math.floor(x * 255.0);
}
function getGradientG(x) {
if (x > 0.25 && x < 0.75) return 255;
if (x < 0.25) x = x * 4.0;
else x = 1.0 - (x - 0.75) * 4.0;
return Math.floor(x * 255.0);
}
function getGradientB(x) {
if (x < 0.25) return 255;
if (x > 0.5) return 0;
x = 1.0 - (x - 0.25) * 4.0;
return Math.floor(x * 255.0);
}
function getRGB(c) {
return `rgb(${getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)})`;
}
function nearestThousand(num) {
return Math.ceil(num / 1000) * 1000;
}
function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
let l = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
let a = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / l;
return {
x: x1 + a * (x2 - x1),
y: y1 + a * (y2 - y1),
};
}
// End Helpers
// Dot Renderers
const drawColorPoints = (u, seriesIdx, idx0, idx1) => {
const size = 5 * devicePixelRatio;
uPlot.orient(
u,
seriesIdx,
(
series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
moveTo,
lineTo,
rect,
arc,
) => {
let d = u.data[seriesIdx];
let deg360 = 2 * Math.PI;
for (let i = 0; i < d[0].length; i++) {
let p = new Path2D();
let xVal = d[0][i];
let yVal = d[1][i];
u.ctx.strokeStyle = getRGB(u.data[2][i]);
u.ctx.fillStyle = getRGB(u.data[2][i]);
if (
xVal >= scaleX.min &&
xVal <= scaleX.max &&
yVal >= scaleY.min &&
yVal <= scaleY.max
) {
let cx = valToPosX(xVal, scaleX, xDim, xOff);
let cy = valToPosY(yVal, scaleY, yDim, yOff);
p.moveTo(cx + size / 2, cy);
arc(p, cx, cy, size / 2, 0, deg360);
}
u.ctx.fill(p);
}
},
);
return null;
};
const drawPoints = (u, seriesIdx, idx0, idx1) => {
const size = 5 * devicePixelRatio;
uPlot.orient(
u,
seriesIdx,
(
series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
moveTo,
lineTo,
rect,
arc,
) => {
let d = u.data[seriesIdx];
u.ctx.strokeStyle = getRGB(0);
u.ctx.fillStyle = getRGB(0);
let deg360 = 2 * Math.PI;
let p = new Path2D();
for (let i = 0; i < d[0].length; i++) {
let xVal = d[0][i];
let yVal = d[1][i];
if (
xVal >= scaleX.min &&
xVal <= scaleX.max &&
yVal >= scaleY.min &&
yVal <= scaleY.max
) {
let cx = valToPosX(xVal, scaleX, xDim, xOff);
let cy = valToPosY(yVal, scaleY, yDim, yOff);
p.moveTo(cx + size / 2, cy);
arc(p, cx, cy, size / 2, 0, deg360);
}
}
u.ctx.fill(p);
},
);
return null;
};
// Main Function
function render(plotData) {
if (plotData) {
const opts = {
title: "",
mode: 2,
width: width,
height: height,
legend: {
show: false,
},
cursor: { drag: { x: false, y: false } },
axes: [
{
label: "Intensity [FLOPS/Byte]",
values: (u, vals) => vals.map((v) => formatNumber(v)),
},
{
label: "Performace [GFLOPS]",
values: (u, vals) => vals.map((v) => formatNumber(v)),
},
],
scales: {
x: {
time: false,
range: [0.01, 1000],
distr: 3, // Render as log
log: 10, // log exp
},
y: {
range: [
1.0,
subCluster?.flopRateSimd?.value
? nearestThousand(subCluster.flopRateSimd.value)
: 10000,
],
distr: 3, // Render as log
log: 10, // log exp
},
},
series: [{}, { paths: renderTime ? drawColorPoints : drawPoints }],
hooks: {
drawClear: [
(u) => {
u.series.forEach((s, i) => {
if (i > 0) s._paths = null;
});
},
],
draw: [
(u) => {
// draw roofs when subCluster set
if (subCluster != null) {
const padding = u._padding; // [top, right, bottom, left]
u.ctx.strokeStyle = "black";
u.ctx.lineWidth = lineWidth;
u.ctx.beginPath();
const ycut = 0.01 * subCluster.memoryBandwidth.value;
const scalarKnee =
(subCluster.flopRateScalar.value - ycut) /
subCluster.memoryBandwidth.value;
const simdKnee =
(subCluster.flopRateSimd.value - ycut) /
subCluster.memoryBandwidth.value;
const scalarKneeX = u.valToPos(scalarKnee, "x", true), // Value, axis, toCanvasPixels
simdKneeX = u.valToPos(simdKnee, "x", true),
flopRateScalarY = u.valToPos(
subCluster.flopRateScalar.value,
"y",
true,
),
flopRateSimdY = u.valToPos(
subCluster.flopRateSimd.value,
"y",
true,
);
if (
scalarKneeX <
width * window.devicePixelRatio -
padding[1] * window.devicePixelRatio
) {
// Lower horizontal roofline
u.ctx.moveTo(scalarKneeX, flopRateScalarY);
u.ctx.lineTo(
width * window.devicePixelRatio -
padding[1] * window.devicePixelRatio,
flopRateScalarY,
);
}
if (
simdKneeX <
width * window.devicePixelRatio -
padding[1] * window.devicePixelRatio
) {
// Top horitontal roofline
u.ctx.moveTo(simdKneeX, flopRateSimdY);
u.ctx.lineTo(
width * window.devicePixelRatio -
padding[1] * window.devicePixelRatio,
flopRateSimdY,
);
}
let x1 = u.valToPos(0.01, "x", true),
y1 = u.valToPos(ycut, "y", true);
let x2 = u.valToPos(simdKnee, "x", true),
y2 = flopRateSimdY;
let xAxisIntersect = lineIntersect(
x1,
y1,
x2,
y2,
u.valToPos(0.01, "x", true),
u.valToPos(1.0, "y", true), // X-Axis Start Coords
u.valToPos(1000, "x", true),
u.valToPos(1.0, "y", true), // X-Axis End Coords
);
if (xAxisIntersect.x > x1) {
x1 = xAxisIntersect.x;
y1 = xAxisIntersect.y;
}
// Diagonal
u.ctx.moveTo(x1, y1);
u.ctx.lineTo(x2, y2);
u.ctx.stroke();
// Reset grid lineWidth
u.ctx.lineWidth = 0.15;
}
if (renderTime) {
// The Color Scale For Time Information
const posX = u.valToPos(0.1, "x", true)
const posXLimit = u.valToPos(100, "x", true)
const posY = u.valToPos(15000.0, "y", true)
u.ctx.fillStyle = 'black'
u.ctx.fillText('Start', posX, posY)
const start = posX + 10
for (let x = start; x < posXLimit; x += 10) {
let c = (x - start) / (posXLimit - start)
u.ctx.fillStyle = getRGB(c)
u.ctx.beginPath()
u.ctx.arc(x, posY, 3, 0, Math.PI * 2, false)
u.ctx.fill()
}
u.ctx.fillStyle = 'black'
u.ctx.fillText('End', posXLimit + 23, posY)
}
},
],
},
// cursor: { drag: { x: true, y: true } } // Activate zoom
};
uplot = new uPlot(opts, plotData, plotWrapper);
} else {
// console.log("No data for roofline!");
}
}
// Svelte and Sizechange
onMount(() => {
render(data);
});
onDestroy(() => {
if (uplot) uplot.destroy();
if (timeoutId != null) clearTimeout(timeoutId);
});
function sizeChanged() {
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
if (uplot) uplot.destroy();
render(data);
}, 200);
}
$: if (allowSizeChange) sizeChanged(width, height);
</script>
{#if data != null}
<div bind:this={plotWrapper} />
{:else}
<Card class="mx-4" body color="warning">Cannot render roofline: No data!</Card
>
{/if}

View File

@@ -0,0 +1,246 @@
<!--
@component Roofline Model Plot as Heatmap of multiple Jobs based on Canvas
Properties:
- `subCluster GraphQL.SubCluster?`: SubCluster Object; contains required topology information [Default: null]
- **Note**: Object of first subCluster is used, how to handle multiple topologies within one cluster? [TODO]
- `tiles [[Float!]!]?`: Data tiles to be rendered [Default: null]
- `maxY Number?`: maximum flopRateSimd of all subClusters [Default: null]
- `width Number?`: Plot width (reactively adaptive) [Default: 500]
- `height Number?`: Plot height (reactively adaptive) [Default: 300]
-->
<script context="module">
const axesColor = '#aaaaaa'
const tickFontSize = 10
const labelFontSize = 12
const fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
const paddingLeft = 40,
paddingRight = 10,
paddingTop = 10,
paddingBottom = 50
function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
let l = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
let a = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / l
return {
x: x1 + a * (x2 - x1),
y: y1 + a * (y2 - y1)
}
}
function axisStepFactor(i, size) {
if (size && size < 500)
return 10
if (i % 3 == 0)
return 2
else if (i % 3 == 1)
return 2.5
else
return 2
}
function render(ctx, data, subCluster, width, height, defaultMaxY) {
if (width <= 0)
return
const [minX, maxX, minY, maxY] = [0.01, 1000, 1., subCluster?.flopRateSimd?.value || defaultMaxY]
const w = width - paddingLeft - paddingRight
const h = height - paddingTop - paddingBottom
// Helpers:
const [log10minX, log10maxX, log10minY, log10maxY] =
[Math.log10(minX), Math.log10(maxX), Math.log10(minY), Math.log10(maxY)]
/* Value -> Pixel-Coordinate */
const getCanvasX = (x) => {
x = Math.log10(x)
x -= log10minX; x /= (log10maxX - log10minX)
return Math.round((x * w) + paddingLeft)
}
const getCanvasY = (y) => {
y = Math.log10(y)
y -= log10minY
y /= (log10maxY - log10minY)
return Math.round((h - y * h) + paddingTop)
}
// Axes
ctx.fillStyle = 'black'
ctx.strokeStyle = axesColor
ctx.font = `${tickFontSize}px ${fontFamily}`
ctx.beginPath()
for (let x = minX, i = 0; x <= maxX; i++) {
let px = getCanvasX(x)
let text = formatNumber(x)
let textWidth = ctx.measureText(text).width
ctx.fillText(text,
Math.floor(px - (textWidth / 2)),
height - paddingBottom + tickFontSize + 5)
ctx.moveTo(px, paddingTop - 5)
ctx.lineTo(px, height - paddingBottom + 5)
x *= axisStepFactor(i, w)
}
if (data.xLabel) {
ctx.font = `${labelFontSize}px ${fontFamily}`
let textWidth = ctx.measureText(data.xLabel).width
ctx.fillText(data.xLabel, Math.floor((width / 2) - (textWidth / 2)), height - 20)
}
ctx.textAlign = 'center'
ctx.font = `${tickFontSize}px ${fontFamily}`
for (let y = minY, i = 0; y <= maxY; i++) {
let py = getCanvasY(y)
ctx.moveTo(paddingLeft - 5, py)
ctx.lineTo(width - paddingRight + 5, py)
ctx.save()
ctx.translate(paddingLeft - 10, py)
ctx.rotate(-Math.PI / 2)
ctx.fillText(formatNumber(y), 0, 0)
ctx.restore()
y *= axisStepFactor(i)
}
if (data.yLabel) {
ctx.font = `${labelFontSize}px ${fontFamily}`
ctx.save()
ctx.translate(15, Math.floor(height / 2))
ctx.rotate(-Math.PI / 2)
ctx.fillText(data.yLabel, 0, 0)
ctx.restore()
}
ctx.stroke()
// Draw Data
if (data.tiles) {
const rows = data.tiles.length
const cols = data.tiles[0].length
const tileWidth = Math.ceil(w / cols)
const tileHeight = Math.ceil(h / rows)
let max = data.tiles.reduce((max, row) =>
Math.max(max, row.reduce((max, val) =>
Math.max(max, val)), 0), 0)
if (max == 0)
max = 1
const tileColor = val => `rgba(255, 0, 0, ${(val / max)})`
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
let px = paddingLeft + (j / cols) * w
let py = paddingTop + (h - (i / rows) * h) - tileHeight
ctx.fillStyle = tileColor(data.tiles[i][j])
ctx.fillRect(px, py, tileWidth, tileHeight)
}
}
}
// Draw roofs
ctx.strokeStyle = 'black'
ctx.lineWidth = 2
ctx.beginPath()
if (subCluster != null) {
const ycut = 0.01 * subCluster.memoryBandwidth.value
const scalarKnee = (subCluster.flopRateScalar.value - ycut) / subCluster.memoryBandwidth.value
const simdKnee = (subCluster.flopRateSimd.value - ycut) / subCluster.memoryBandwidth.value
const scalarKneeX = getCanvasX(scalarKnee),
simdKneeX = getCanvasX(simdKnee),
flopRateScalarY = getCanvasY(subCluster.flopRateScalar.value),
flopRateSimdY = getCanvasY(subCluster.flopRateSimd.value)
if (scalarKneeX < width - paddingRight) {
ctx.moveTo(scalarKneeX, flopRateScalarY)
ctx.lineTo(width - paddingRight, flopRateScalarY)
}
if (simdKneeX < width - paddingRight) {
ctx.moveTo(simdKneeX, flopRateSimdY)
ctx.lineTo(width - paddingRight, flopRateSimdY)
}
let x1 = getCanvasX(0.01),
y1 = getCanvasY(ycut),
x2 = getCanvasX(simdKnee),
y2 = flopRateSimdY
let xAxisIntersect = lineIntersect(
x1, y1, x2, y2,
0, height - paddingBottom, width, height - paddingBottom)
if (xAxisIntersect.x > x1) {
x1 = xAxisIntersect.x
y1 = xAxisIntersect.y
}
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
}
ctx.stroke()
}
</script>
<script>
import { onMount } from 'svelte'
import { formatNumber } from '../units.js'
export let subCluster = null
export let tiles = null
export let maxY = null
export let width = 500
export let height = 300
console.assert(tiles, "you must provide tiles!")
let ctx, canvasElement, prevWidth = width, prevHeight = height
const data = {
tiles: tiles,
xLabel: 'Intensity [FLOPS/byte]',
yLabel: 'Performance [GFLOPS]'
}
onMount(() => {
ctx = canvasElement.getContext('2d')
if (prevWidth != width || prevHeight != height) {
sizeChanged()
return
}
canvasElement.width = width
canvasElement.height = height
render(ctx, data, subCluster, width, height, maxY)
})
let timeoutId = null
function sizeChanged() {
if (!ctx)
return
if (timeoutId != null)
clearTimeout(timeoutId)
prevWidth = width
prevHeight = height
timeoutId = setTimeout(() => {
if (!canvasElement)
return
timeoutId = null
canvasElement.width = width
canvasElement.height = height
render(ctx, data, subCluster, width, height, maxY)
}, 250)
}
$: sizeChanged(width, height)
</script>
<div class="cc-plot">
<canvas bind:this={canvasElement} width="{prevWidth}" height="{prevHeight}"></canvas>
</div>

View File

@@ -0,0 +1,185 @@
<!--
@component Scatter plot of two metrics at identical timesteps, based on canvas
Properties:
- `X [Number]`: Data from first selected metric as X-values
- `Y [Number]`: Data from second selected metric as Y-values
- `S GraphQl.TimeWeights.X?`: Float to scale the data with [Default: null]
- `color String`: Color of the drawn scatter circles
- `width Number`:
- `height Number`:
- `xLabel String`:
- `yLabel String`:
-->
<script context="module">
import { formatNumber } from '../units.js'
const axesColor = '#aaaaaa'
const fontSize = 12
const fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
const paddingLeft = 40,
paddingRight = 10,
paddingTop = 10,
paddingBottom = 50
function getStepSize(valueRange, pixelRange, minSpace) {
const proposition = valueRange / (pixelRange / minSpace);
const getStepSize = n => Math.pow(10, Math.floor(n / 3)) *
(n < 0 ? [1., 5., 2.][-n % 3] : [1., 2., 5.][n % 3]);
let n = 0;
let stepsize = getStepSize(n);
while (true) {
let bigger = getStepSize(n + 1);
if (proposition > bigger) {
n += 1;
stepsize = bigger;
} else {
return stepsize;
}
}
}
function render(ctx, X, Y, S, color, xLabel, yLabel, width, height) {
if (width <= 0)
return;
const [minX, minY] = [0., 0.];
let maxX = X.reduce((maxX, x) => Math.max(maxX, x), minX);
let maxY = Y.reduce((maxY, y) => Math.max(maxY, y), minY);
const w = width - paddingLeft - paddingRight;
const h = height - paddingTop - paddingBottom;
if (maxX == 0 && maxY == 0) {
maxX = 1;
maxY = 1;
}
/* Value -> Pixel-Coordinate */
const getCanvasX = (x) => {
x -= minX; x /= (maxX - minX);
return Math.round((x * w) + paddingLeft);
};
const getCanvasY = (y) => {
y -= minY; y /= (maxY - minY);
return Math.round((h - y * h) + paddingTop);
};
// Draw Data
let size = 3
if (S) {
let max = S.reduce((max, s, i) => (X[i] == null || Y[i] == null || Number.isNaN(X[i]) || Number.isNaN(Y[i])) ? max : Math.max(max, s), 0)
size = (w / 15) / max
}
ctx.fillStyle = color;
for (let i = 0; i < X.length; i++) {
let x = X[i], y = Y[i];
if (x == null || y == null || Number.isNaN(x) || Number.isNaN(y))
continue;
const s = S ? S[i] * size : size;
const px = getCanvasX(x);
const py = getCanvasY(y);
ctx.beginPath();
ctx.arc(px, py, s, 0, Math.PI * 2, false);
ctx.fill();
}
// Axes
ctx.fillStyle = '#000000'
ctx.strokeStyle = axesColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.beginPath();
const stepsizeX = getStepSize(maxX, w, 75);
for (let x = minX, i = 0; x <= maxX; i++) {
let px = getCanvasX(x);
let text = formatNumber(x);
let textWidth = ctx.measureText(text).width;
ctx.fillText(text,
Math.floor(px - (textWidth / 2)),
height - paddingBottom + fontSize + 5);
ctx.moveTo(px, paddingTop - 5);
ctx.lineTo(px, height - paddingBottom + 5);
x += stepsizeX;
}
if (xLabel) {
let textWidth = ctx.measureText(xLabel).width;
ctx.fillText(xLabel, Math.floor((width / 2) - (textWidth / 2)), height - 20);
}
ctx.textAlign = 'center';
const stepsizeY = getStepSize(maxY, h, 75);
for (let y = minY, i = 0; y <= maxY; i++) {
let py = getCanvasY(y);
ctx.moveTo(paddingLeft - 5, py);
ctx.lineTo(width - paddingRight + 5, py);
ctx.save();
ctx.translate(paddingLeft - 10, py);
ctx.rotate(-Math.PI / 2);
ctx.fillText(formatNumber(y), 0, 0);
ctx.restore();
y += stepsizeY;
}
if (yLabel) {
ctx.save();
ctx.translate(15, Math.floor(height / 2));
ctx.rotate(-Math.PI / 2);
ctx.fillText(yLabel, 0, 0);
ctx.restore();
}
ctx.stroke();
}
</script>
<script>
import { onMount } from 'svelte';
export let X;
export let Y;
export let S = null;
export let color = '#0066cc';
export let width;
export let height;
export let xLabel;
export let yLabel;
let ctx;
let canvasElement;
onMount(() => {
canvasElement.width = width;
canvasElement.height = height;
ctx = canvasElement.getContext('2d');
render(ctx, X, Y, S, color, xLabel, yLabel, width, height);
});
let timeoutId = null;
function sizeChanged() {
if (timeoutId != null)
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
if (!canvasElement)
return;
canvasElement.width = width;
canvasElement.height = height;
ctx = canvasElement.getContext('2d');
render(ctx, X, Y, S, color, xLabel, yLabel, width, height);
}, 250);
}
$: sizeChanged(width, height);
</script>
<div class="cc-plot">
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
</div>

View File

@@ -0,0 +1,304 @@
<!--
Copyright (c) 2021 Michael Keller
Originally created by Michael Keller (https://github.com/mhkeller/svelte-double-range-slider)
Changes: remove dependency, text inputs, configurable value ranges, on:change event
-->
<!--
@component Selector component to display range selections via min and max double-sliders
Properties:
- min: Number
- max: Number
- firstSlider: Number (Starting position of slider #1)
- secondSlider: Number (Starting position of slider #2)
Events:
- `change`: [Number, Number] (Positions of the two sliders)
-->
<script>
import { createEventDispatcher } from "svelte";
export let min;
export let max;
export let firstSlider;
export let secondSlider;
export let inputFieldFrom = 0;
export let inputFieldTo = 0;
const dispatch = createEventDispatcher();
let values;
let start, end; /* Positions of sliders from 0 to 1 */
$: values = [firstSlider, secondSlider]; /* Avoid feedback loop */
$: start = Math.max(((firstSlider == null ? min : firstSlider) - min) / (max - min), 0);
$: end = Math.min(((secondSlider == null ? min : secondSlider) - min) / (max - min), 1);
let leftHandle;
let body;
let slider;
let timeoutId = null;
function queueChangeEvent() {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
timeoutId = null;
// Show selection but avoid feedback loop
if (values[0] != null && inputFieldFrom != values[0].toString())
inputFieldFrom = values[0].toString();
if (values[1] != null && inputFieldTo != values[1].toString())
inputFieldTo = values[1].toString();
dispatch('change', values);
}, 250);
}
function update() {
values = [
Math.floor(min + start * (max - min)),
Math.floor(min + end * (max - min))
];
queueChangeEvent();
}
function inputChanged(idx, event) {
let val = Number.parseInt(event.target.value);
if (Number.isNaN(val) || val < min) {
event.target.classList.add('bad');
return;
}
values[idx] = val;
event.target.classList.remove('bad');
if (idx == 0)
start = clamp((val - min) / (max - min), 0., 1.);
else
end = clamp((val - min) / (max - min), 0., 1.);
queueChangeEvent();
}
function clamp(x, min, max) {
return x < min
? min
: (x < max ? x : max);
}
function draggable(node) {
let x;
let y;
function handleMousedown(event) {
if (event.type === 'touchstart') {
event = event.touches[0];
}
x = event.clientX;
y = event.clientY;
node.dispatchEvent(new CustomEvent('dragstart', {
detail: { x, y }
}));
window.addEventListener('mousemove', handleMousemove);
window.addEventListener('mouseup', handleMouseup);
window.addEventListener('touchmove', handleMousemove);
window.addEventListener('touchend', handleMouseup);
}
function handleMousemove(event) {
if (event.type === 'touchmove') {
event = event.changedTouches[0];
}
const dx = event.clientX - x;
const dy = event.clientY - y;
x = event.clientX;
y = event.clientY;
node.dispatchEvent(new CustomEvent('dragmove', {
detail: { x, y, dx, dy }
}));
}
function handleMouseup(event) {
x = event.clientX;
y = event.clientY;
node.dispatchEvent(new CustomEvent('dragend', {
detail: { x, y }
}));
window.removeEventListener('mousemove', handleMousemove);
window.removeEventListener('mouseup', handleMouseup);
window.removeEventListener('touchmove', handleMousemove);
window.removeEventListener('touchend', handleMouseup);
}
node.addEventListener('mousedown', handleMousedown);
node.addEventListener('touchstart', handleMousedown);
return {
destroy() {
node.removeEventListener('mousedown', handleMousedown);
node.removeEventListener('touchstart', handleMousedown);
}
};
}
function setHandlePosition (which) {
return function (evt) {
const { left, right } = slider.getBoundingClientRect();
const parentWidth = right - left;
const p = Math.min(Math.max((evt.detail.x - left) / parentWidth, 0), 1);
if (which === 'start') {
start = p;
end = Math.max(end, p);
} else {
start = Math.min(p, start);
end = p;
}
update();
}
}
function setHandlesFromBody (evt) {
const { width } = body.getBoundingClientRect();
const { left, right } = slider.getBoundingClientRect();
const parentWidth = right - left;
const leftHandleLeft = leftHandle.getBoundingClientRect().left;
const pxStart = clamp((leftHandleLeft + evt.detail.dx) - left, 0, parentWidth - width);
const pxEnd = clamp(pxStart + width, width, parentWidth);
const pStart = pxStart / parentWidth;
const pEnd = pxEnd / parentWidth;
start = pStart;
end = pEnd;
update();
}
</script>
<div class="double-range-container">
<div class="header">
<input class="form-control" type="text" placeholder="from..." bind:value={inputFieldFrom}
on:input={(e) => inputChanged(0, e)} />
<span>Full Range: <b> {min} </b> - <b> {max} </b></span>
<input class="form-control" type="text" placeholder="to..." bind:value={inputFieldTo}
on:input={(e) => inputChanged(1, e)} />
</div>
<div class="slider" bind:this={slider}>
<div
class="body"
bind:this={body}
use:draggable
on:dragmove|preventDefault|stopPropagation="{setHandlesFromBody}"
style="
left: {100 * start}%;
right: {100 * (1 - end)}%;
"
></div>
<div
class="handle"
bind:this={leftHandle}
data-which="start"
use:draggable
on:dragmove|preventDefault|stopPropagation="{setHandlePosition('start')}"
style="
left: {100 * start}%
"
></div>
<div
class="handle"
data-which="end"
use:draggable
on:dragmove|preventDefault|stopPropagation="{setHandlePosition('end')}"
style="
left: {100 * end}%
"
></div>
</div>
</div>
<style>
.header {
width: 100%;
display: flex;
justify-content: space-between;
margin-bottom: -5px;
}
.header :nth-child(2) {
padding-top: 10px;
}
.header input {
height: 25px;
border-radius: 5px;
width: 100px;
}
:global(.double-range-container .header input[type="text"].bad) {
color: #ff5c33;
border-color: #ff5c33;
}
.double-range-container {
width: 100%;
height: 50px;
user-select: none;
box-sizing: border-box;
white-space: nowrap
}
.slider {
position: relative;
width: 100%;
height: 6px;
top: 10px;
transform: translate(0, -50%);
background-color: #e2e2e2;
box-shadow: inset 0 7px 10px -5px #4a4a4a, inset 0 -1px 0px 0px #9c9c9c;
border-radius: 6px;
}
.handle {
position: absolute;
top: 50%;
width: 0;
height: 0;
}
.handle:after {
content: ' ';
box-sizing: border-box;
position: absolute;
border-radius: 50%;
width: 16px;
height: 16px;
background-color: #fdfdfd;
border: 1px solid #7b7b7b;
transform: translate(-50%, -50%)
}
/* .handle[data-which="end"]:after{
transform: translate(-100%, -50%);
} */
.handle:active:after {
background-color: #ddd;
z-index: 9;
}
.body {
top: 0;
position: absolute;
background-color: #34a1ff;
bottom: 0;
}
</style>

View File

@@ -0,0 +1,94 @@
<!--
@component Selector component for (footprint) metrics to be displayed as histogram
Properties:
- `cluster String`: Currently selected cluster
- `metricsInHistograms [String]`: The currently selected metrics to display as histogram
- ìsOpen Bool`: Is selection opened
-->
<script>
import { getContext } from "svelte";
import {
Modal,
ModalBody,
ModalHeader,
ModalFooter,
Button,
ListGroup,
ListGroupItem,
} from "@sveltestrap/sveltestrap";
import { gql, getContextClient, mutationStore } from "@urql/svelte";
export let cluster;
export let metricsInHistograms;
export let isOpen;
const client = getContextClient();
const initialized = getContext("initialized");
let availableMetrics = []
function loadHistoMetrics(isInitialized) {
if (!isInitialized) return;
const rawAvailableMetrics = getContext("globalMetrics").filter((gm) => gm?.footprint).map((fgm) => { return fgm.name })
availableMetrics = [...rawAvailableMetrics]
}
let pendingMetrics = [...metricsInHistograms]; // Copy
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(data) {
updateConfigurationMutation({
name: data.name,
value: JSON.stringify(data.value),
}).subscribe((res) => {
if (res.fetching === false && res.error) {
throw res.error;
}
});
}
function closeAndApply() {
metricsInHistograms = [...pendingMetrics]; // Set for parent
isOpen = !isOpen;
updateConfiguration({
name: cluster
? `user_view_histogramMetrics:${cluster}`
: "user_view_histogramMetrics",
value: metricsInHistograms,
});
}
$: loadHistoMetrics($initialized);
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>Select metrics presented in histograms</ModalHeader>
<ModalBody>
<ListGroup>
{#each availableMetrics as metric (metric)}
<ListGroupItem>
<input type="checkbox" bind:group={pendingMetrics} value={metric} />
{metric}
</ListGroupItem>
{/each}
</ListGroup>
</ModalBody>
<ModalFooter>
<Button color="primary" on:click={closeAndApply}>Close & Apply</Button>
<Button color="secondary" on:click={() => (isOpen = !isOpen)}>Close</Button>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,192 @@
<!--
@component Metric selector component; allows reorder via drag and drop
Properties:
- `metrics [String]`: (changes from inside, needs to be initialised, list of selected metrics)
- `isOpen Bool`: (can change from inside and outside)
- `configName String`: The config key for the last saved selection (constant)
- `allMetrics [String]?`: List of all available metrics [Default: null]
- `cluster String?`: The currently selected cluster [Default: null]
- `showFootprint Bool?`: Upstream state of wether to render footpritn card [Default: false]
- `footprintSelect Bool?`: Render checkbox for footprint display in upstream component [Default: false]
-->
<script>
import { getContext } from "svelte";
import {
Modal,
ModalBody,
ModalHeader,
ModalFooter,
Button,
ListGroup,
} from "@sveltestrap/sveltestrap";
import { gql, getContextClient, mutationStore } from "@urql/svelte";
export let metrics;
export let isOpen;
export let configName;
export let allMetrics = null;
export let cluster = null;
export let showFootprint = false;
export let footprintSelect = false;
const onInit = getContext("on-init")
const globalMetrics = getContext("globalMetrics")
let newMetricsOrder = [];
let unorderedMetrics = [...metrics];
let pendingShowFootprint = !!showFootprint;
onInit(() => {
if (allMetrics == null) allMetrics = new Set();
for (let metric of globalMetrics) allMetrics.add(metric.name);
});
$: {
if (allMetrics != null) {
if (cluster == null) {
for (let metric of globalMetrics) allMetrics.add(metric.name);
} else {
allMetrics.clear();
for (let gm of globalMetrics) {
if (gm.availability.find((av) => av.cluster === cluster)) 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));
}
}
function printAvailability(metric, cluster) {
const avail = globalMetrics.find((gm) => gm.name === metric)?.availability
if (cluster == null) {
return avail.map((av) => av.cluster).join(',')
} else {
return avail.find((av) => av.cluster === cluster).subClusters.join(',')
}
}
const client = getContextClient();
const updateConfigurationMutation = ({ name, value }) => {
return mutationStore({
client: client,
query: gql`
mutation ($name: String!, $value: String!) {
updateConfiguration(name: $name, value: $value)
}
`,
variables: { name, value },
});
};
let columnHovering = null;
function columnsDragStart(event, i) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.dropEffect = "move";
event.dataTransfer.setData("text/plain", i);
}
function columnsDrag(event, target) {
event.dataTransfer.dropEffect = "move";
const start = Number.parseInt(event.dataTransfer.getData("text/plain"));
if (start < target) {
newMetricsOrder.splice(target + 1, 0, newMetricsOrder[start]);
newMetricsOrder.splice(start, 1);
} else {
newMetricsOrder.splice(target, 0, newMetricsOrder[start]);
newMetricsOrder.splice(start + 1, 1);
}
columnHovering = null;
}
function closeAndApply() {
metrics = newMetricsOrder.filter((m) => unorderedMetrics.includes(m));
isOpen = false;
showFootprint = !!pendingShowFootprint;
updateConfigurationMutation({
name: cluster == null ? configName : `${configName}:${cluster}`,
value: JSON.stringify(metrics),
}).subscribe((res) => {
if (res.fetching === false && res.error) {
throw res.error;
}
});
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;
}
});
}
</script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>Configure columns (Metric availability shown)</ModalHeader>
<ModalBody>
<ListGroup>
{#if footprintSelect}
<li class="list-group-item">
<input type="checkbox" bind:checked={pendingShowFootprint} /> Show Footprint
</li>
<hr />
{/if}
{#each newMetricsOrder as metric, index (metric)}
<li
class="cc-config-column list-group-item"
draggable={true}
ondragover="return false"
on:dragstart={(event) => columnsDragStart(event, index)}
on:drop|preventDefault={(event) => columnsDrag(event, index)}
on:dragenter={() => (columnHovering = index)}
class:is-active={columnHovering === index}
>
{#if unorderedMetrics.includes(metric)}
<input
type="checkbox"
bind:group={unorderedMetrics}
value={metric}
checked
/>
{:else}
<input
type="checkbox"
bind:group={unorderedMetrics}
value={metric}
/>
{/if}
{metric}
<span style="float: right;">
{printAvailability(metric, cluster)}
</span>
</li>
{/each}
</ListGroup>
</ModalBody>
<ModalFooter>
<Button color="primary" on:click={closeAndApply}>Close & Apply</Button>
</ModalFooter>
</Modal>
<style>
li.cc-config-column {
display: block;
cursor: grab;
}
li.cc-config-column.is-active {
background-color: #3273dc;
color: #fff;
cursor: grabbing;
}
</style>

View File

@@ -0,0 +1,115 @@
<!--
@component Selector for sorting field and direction
Properties:
- sorting: { field: String, order: "DESC" | "ASC" } (changes from inside)
- isOpen: Boolean (can change from inside and outside)
-->
<script>
import { getContext } from "svelte";
import {
Icon,
Button,
ListGroup,
ListGroupItem,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
} from "@sveltestrap/sveltestrap";
import { getSortItems } from "../utils.js";
export let isOpen = false;
export let sorting = { field: "startTime", type: "col", order: "DESC" };
let sortableColumns = [];
let activeColumnIdx;
const initialized = getContext("initialized");
function loadSortables(isInitialized) {
if (!isInitialized) return;
sortableColumns = [
{ field: "startTime", type: "col", text: "Start Time", order: "DESC" },
{ field: "duration", type: "col", text: "Duration", order: "DESC" },
{ field: "numNodes", type: "col", text: "Number of Nodes", order: "DESC" },
{ field: "numHwthreads", type: "col", text: "Number of HWThreads", order: "DESC" },
{ field: "numAcc", type: "col", text: "Number of Accelerators", order: "DESC" },
...getSortItems()
]
}
function loadActiveIndex(isInitialized) {
if (!isInitialized) return;
activeColumnIdx = sortableColumns.findIndex(
(col) => col.field == sorting.field,
);
sortableColumns[activeColumnIdx].order = sorting.order;
}
$: loadSortables($initialized);
$: loadActiveIndex($initialized)
</script>
<Modal
{isOpen}
toggle={() => {
isOpen = !isOpen;
}}
>
<ModalHeader>Sort rows</ModalHeader>
<ModalBody>
<ListGroup>
{#each sortableColumns as col, i (col)}
<ListGroupItem>
<button
class="sort"
on:click={() => {
if (activeColumnIdx == i) {
col.order = col.order == "DESC" ? "ASC" : "DESC";
} else {
sortableColumns[activeColumnIdx] = {
...sortableColumns[activeColumnIdx],
};
}
sortableColumns[i] = { ...sortableColumns[i] };
activeColumnIdx = i;
sortableColumns = [...sortableColumns];
sorting = { field: col.field, type: col.type, order: col.order };
}}
>
<Icon
name="arrow-{col.order == 'DESC' ? 'down' : 'up'}-circle{i ==
activeColumnIdx
? '-fill'
: ''}"
/>
</button>
{col.text}
</ListGroupItem>
{/each}
</ListGroup>
</ModalBody>
<ModalFooter>
<Button
color="primary"
on:click={() => {
isOpen = false;
}}>Close</Button
>
</ModalFooter>
</Modal>
<style>
.sort {
border: none;
margin: 0;
padding: 0;
background: 0 0;
transition: all 70ms;
}
</style>

View File

@@ -0,0 +1,96 @@
<!--
@component Selector for specified real time ranges for data cutoff; used in systems and nodes view
Properties:
- `from Date`: The datetime to start data display from
- `to Date`: The datetime to end data display at
- `customEnabled Bool?`: Allow custom time window selection [Default: true]
- `options Object? {String:Number}`: The quick time selection options [Default: {..., "Last 24hrs": 24*60*60}]
Events:
- `change, {Date, Date}`: Set 'from, to' values in upstream component
-->
<script>
import { createEventDispatcher } from "svelte";
import {
Icon,
Input,
InputGroup,
InputGroupText,
} from "@sveltestrap/sveltestrap";
export let from;
export let to;
export let customEnabled = true;
export let options = {
"Last quarter hour": 15 * 60,
"Last half hour": 30 * 60,
"Last hour": 60 * 60,
"Last 2hrs": 2 * 60 * 60,
"Last 4hrs": 4 * 60 * 60,
"Last 12hrs": 12 * 60 * 60,
"Last 24hrs": 24 * 60 * 60,
};
$: pendingFrom = from;
$: pendingTo = to;
const dispatch = createEventDispatcher();
let timeRange = // If both times set, return diff, else: display custom select
(to && from) ? ((to.getTime() - from.getTime()) / 1000) : -1;
function updateTimeRange() {
if (timeRange == -1) {
pendingFrom = null;
pendingTo = null;
return;
}
let now = Date.now(),
t = timeRange * 1000;
from = pendingFrom = new Date(now - t);
to = pendingTo = new Date(now);
dispatch("change", { from, to });
}
function updateExplicitTimeRange(type, event) {
let d = new Date(Date.parse(event.target.value));
if (type == "from") pendingFrom = d;
else pendingTo = d;
if (pendingFrom != null && pendingTo != null) {
from = pendingFrom;
to = pendingTo;
dispatch("change", { from, to });
}
}
</script>
<InputGroup class="inline-from">
<InputGroupText><Icon name="clock-history" /></InputGroupText>
<select
class="form-select"
bind:value={timeRange}
on:change={updateTimeRange}
>
{#if customEnabled}
<option value={-1}>Custom</option>
{/if}
{#each Object.entries(options) as [name, seconds]}
<option value={seconds}>{name}</option>
{/each}
</select>
{#if timeRange == -1}
<InputGroupText>from</InputGroupText>
<Input
type="datetime-local"
on:change={(event) => updateExplicitTimeRange("from", event)}
></Input>
<InputGroupText>to</InputGroupText>
<Input
type="datetime-local"
on:change={(event) => updateExplicitTimeRange("to", event)}
></Input>
{/if}
</InputGroup>

View File

@@ -0,0 +1,34 @@
/*
Collect Functions for Unit Handling and Scaling Here
*/
const power = [1, 1e3, 1e6, 1e9, 1e12, 1e15, 1e18, 1e21]
const prefix = ['', 'K', 'M', 'G', 'T', 'P', 'E']
export function formatNumber(x) {
if ( isNaN(x) || x == null) {
return x // Return if String or Null
} else {
for (let i = 0; i < prefix.length; i++)
if (power[i] <= x && x < power[i+1])
return `${Math.round((x / power[i]) * 100) / 100} ${prefix[i]}`
return Math.abs(x) >= 1000 ? x.toExponential() : x.toString()
}
}
export function scaleNumbers(x, y , p = '') {
const oldPower = power[prefix.indexOf(p)]
const rawXValue = x * oldPower
const rawYValue = y * oldPower
for (let i = 0; i < prefix.length; i++) {
if (power[i] <= rawYValue && rawYValue < power[i+1]) {
return `${Math.round((rawXValue / power[i]) * 100) / 100} / ${Math.round((rawYValue / power[i]) * 100) / 100} ${prefix[i]}`
}
}
return Math.abs(rawYValue) >= 1000 ? `${rawXValue.toExponential()} / ${rawYValue.toExponential()}` : `${rawYValue.toString()} / ${rawYValue.toString()}`
}
// export const dateToUnixEpoch = (rfc3339) => Math.floor(Date.parse(rfc3339) / 1000);

View File

@@ -0,0 +1,539 @@
import { expiringCacheExchange } from "./cache-exchange.js";
import {
Client,
setContextClient,
fetchExchange,
} from "@urql/svelte";
import { setContext, getContext, hasContext, onDestroy, tick } from "svelte";
import { readable } from "svelte/store";
/*
* Call this function only at component initialization time!
*
* It does several things:
* - Initialize the GraphQL client
* - Creates a readable store 'initialization' which indicates when the values below can be used.
* - Adds 'tags' to the context (list of all tags)
* - Adds 'clusters' to the context (object with cluster names as keys)
* - Adds 'globalMetrics' to the context (list of globally available metric infos)
* - Adds 'getMetricConfig' to the context, a function that takes a cluster, subCluster and metric name and returns the MetricConfig (or undefined)
* - Adds 'getHardwareTopology' to the context, a function that takes a cluster nad subCluster and returns the subCluster topology (or undefined)
*/
export function init(extraInitQuery = "") {
const jwt = hasContext("jwt")
? getContext("jwt")
: getContext("cc-config")["jwt"];
const client = new Client({
url: `${window.location.origin}/query`,
fetchOptions:
jwt != null ? { headers: { Authorization: `Bearer ${jwt}` } } : {},
exchanges: [
expiringCacheExchange({
ttl: 5 * 60 * 1000,
maxSize: 150,
}),
fetchExchange,
],
});
setContextClient(client);
const query = client
.query(
`query {
clusters {
name
partitions
subClusters {
name
nodes
numberOfNodes
processorType
socketsPerNode
coresPerSocket
threadsPerCore
flopRateScalar { unit { base, prefix }, value }
flopRateSimd { unit { base, prefix }, value }
memoryBandwidth { unit { base, prefix }, value }
topology {
node
socket
core
accelerators { id }
}
metricConfig {
name
unit { base, prefix }
scope
aggregation
timestep
peak
normal
caution
alert
lowerIsBetter
}
footprint
}
}
tags { id, name, type }
globalMetrics {
name
scope
footprint
unit { base, prefix }
availability { cluster, subClusters }
}
${extraInitQuery}
}`
)
.toPromise();
let state = { fetching: true, error: null, data: null };
let subscribers = [];
const subscribe = (callback) => {
callback(state);
subscribers.push(callback);
return () => {
subscribers = subscribers.filter((cb) => cb != callback);
};
};
const tags = []
const clusters = []
const globalMetrics = []
setContext("tags", tags);
setContext("clusters", clusters);
setContext("globalMetrics", globalMetrics);
setContext("getMetricConfig", (cluster, subCluster, metric) => {
// Load objects if input is string
if (typeof cluster !== "object")
cluster = clusters.find((c) => c.name == cluster);
if (typeof subCluster !== "object")
subCluster = cluster.subClusters.find((sc) => sc.name == subCluster);
return subCluster.metricConfig.find((m) => m.name == metric);
});
setContext("getHardwareTopology", (cluster, subCluster) => {
// Load objects if input is string
if (typeof cluster !== "object")
cluster = clusters.find((c) => c.name == cluster);
if (typeof subCluster !== "object")
subCluster = cluster.subClusters.find((sc) => sc.name == subCluster);
return subCluster?.topology;
});
setContext("on-init", (callback) =>
state.fetching ? subscribers.push(callback) : callback(state)
);
setContext(
"initialized",
readable(false, (set) => subscribers.push(() => set(true)))
);
query.then(({ error, data }) => {
state.fetching = false;
if (error != null) {
console.error(error);
state.error = error;
tick().then(() => subscribers.forEach((cb) => cb(state)));
return;
}
for (let tag of data.tags) tags.push(tag);
for (let cluster of data.clusters) clusters.push(cluster);
for (let gm of data.globalMetrics) globalMetrics.push(gm);
// Unified Sort
globalMetrics.sort((a, b) => a.name.localeCompare(b.name))
state.data = data;
tick().then(() => subscribers.forEach((cb) => cb(state)));
});
return {
query: { subscribe },
tags,
clusters,
globalMetrics
};
}
// Use https://developer.mozilla.org/en-US/docs/Web/API/structuredClone instead?
export function deepCopy(x) {
return JSON.parse(JSON.stringify(x));
}
function fuzzyMatch(term, string) {
return string.toLowerCase().includes(term);
}
// Use in filter() function to return only unique values
export function distinct(value, index, array) {
return array.indexOf(value) === index;
}
// Load Local Bool and Handle Scrambling of input string
export const scrambleNames = window.localStorage.getItem("cc-scramble-names");
export const scramble = function (str) {
if (str === "-") return str;
else
return [...str]
.reduce((x, c, i) => x * 7 + c.charCodeAt(0) * i * 21, 5)
.toString(32)
.substr(0, 6);
};
export function fuzzySearchTags(term, tags) {
if (!tags) return [];
let results = [];
let termparts = term
.split(":")
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (termparts.length == 0) {
results = tags.slice();
} else if (termparts.length == 1) {
for (let tag of tags)
if (
fuzzyMatch(termparts[0], tag.type) ||
fuzzyMatch(termparts[0], tag.name)
)
results.push(tag);
} else if (termparts.length == 2) {
for (let tag of tags)
if (
fuzzyMatch(termparts[0], tag.type) &&
fuzzyMatch(termparts[1], tag.name)
)
results.push(tag);
}
return results.sort((a, b) => {
if (a.type < b.type) return -1;
if (a.type > b.type) return 1;
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});
}
export function groupByScope(jobMetrics) {
let metrics = new Map();
for (let metric of jobMetrics) {
if (metrics.has(metric.name)) metrics.get(metric.name).push(metric);
else metrics.set(metric.name, [metric]);
}
return [...metrics.values()].sort((a, b) =>
a[0].name.localeCompare(b[0].name)
);
}
const scopeGranularity = {
node: 10,
socket: 5,
memorydomain: 4,
core: 3,
hwthread: 2,
accelerator: 1
};
export function maxScope(scopes) {
console.assert(
scopes.length > 0 && scopes.every((x) => scopeGranularity[x] != null)
);
let sm = scopes[0],
gran = scopeGranularity[scopes[0]];
for (let scope of scopes) {
let otherGran = scopeGranularity[scope];
if (otherGran > gran) {
sm = scope;
gran = otherGran;
}
}
return sm;
}
export function minScope(scopes) {
console.assert(
scopes.length > 0 && scopes.every((x) => scopeGranularity[x] != null)
);
let sm = scopes[0],
gran = scopeGranularity[scopes[0]];
for (let scope of scopes) {
let otherGran = scopeGranularity[scope];
if (otherGran < gran) {
sm = scope;
gran = otherGran;
}
}
return sm;
}
export function stickyHeader(datatableHeaderSelector, updatePading) {
const header = document.querySelector("header > nav.navbar");
if (!header) return;
let ticking = false,
datatableHeader = null;
const onscroll = (event) => {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(() => {
ticking = false;
if (!datatableHeader)
datatableHeader = document.querySelector(datatableHeaderSelector);
const top = datatableHeader.getBoundingClientRect().top;
updatePading(
top < header.clientHeight ? header.clientHeight - top + 10 : 10
);
});
};
document.addEventListener("scroll", onscroll);
onDestroy(() => document.removeEventListener("scroll", onscroll));
}
export function checkMetricDisabled(m, c, s) { //[m]etric, [c]luster, [s]ubcluster
const metrics = getContext("globalMetrics");
const result = metrics?.find((gm) => gm.name === m)?.availability?.find((av) => av.cluster === c)?.subClusters?.includes(s)
return !result
}
export function getStatsItems() {
// console.time('stats')
// console.log('getStatsItems ...')
const globalMetrics = getContext("globalMetrics")
const result = globalMetrics.map((gm) => {
if (gm?.footprint) {
// Footprint contains suffix naming the used stat-type
// console.time('deep')
// console.log('Deep Config for', gm.name)
const mc = getMetricConfigDeep(gm.name, null, null)
// console.timeEnd('deep')
return {
field: gm.name + '_' + gm.footprint,
text: gm.name + ' (' + gm.footprint + ')',
metric: gm.name,
from: 0,
to: mc.peak,
peak: mc.peak,
enabled: false
}
}
return null
}).filter((r) => r != null)
// console.timeEnd('stats')
return [...result];
};
export function getSortItems() {
//console.time('sort')
//console.log('getSortItems ...')
const globalMetrics = getContext("globalMetrics")
const result = globalMetrics.map((gm) => {
if (gm?.footprint) {
// Footprint contains suffix naming the used stat-type
return {
field: gm.name + '_' + gm.footprint,
type: 'foot',
text: gm.name + ' (' + gm.footprint + ')',
order: 'DESC'
}
}
return null
}).filter((r) => r != null)
//console.timeEnd('sort')
return [...result];
};
function getMetricConfigDeep(metric, cluster, subCluster) {
const clusters = getContext("clusters");
if (cluster != null) {
let c = clusters.find((c) => c.name == cluster);
if (subCluster != null) {
let sc = c.subClusters.find((sc) => sc.name == subCluster);
return sc.metricConfig.find((mc) => mc.name == metric)
} else {
let result;
for (let sc of c.subClusters) {
const mc = sc.metricConfig.find((mc) => mc.name == metric)
if (result) { // If lowerIsBetter: Peak is still maximum value, no special case required
result.alert = (mc.alert > result.alert) ? mc.alert : result.alert
result.caution = (mc.caution > result.caution) ? mc.caution : result.caution
result.normal = (mc.normal > result.normal) ? mc.normal : result.normal
result.peak = (mc.peak > result.peak) ? mc.peak : result.peak
} else {
if (mc) result = {...mc};
}
}
return result
}
} else {
let result;
for (let c of clusters) {
for (let sc of c.subClusters) {
const mc = sc.metricConfig.find((mc) => mc.name == metric)
if (result) { // If lowerIsBetter: Peak is still maximum value, no special case required
result.alert = (mc.alert > result.alert) ? mc.alert : result.alert
result.caution = (mc.caution > result.caution) ? mc.caution : result.caution
result.normal = (mc.normal > result.normal) ? mc.normal : result.normal
result.peak = (mc.peak > result.peak) ? mc.peak : result.peak
} else {
if (mc) result = {...mc};
}
}
}
return result
}
}
export function convert2uplot(canvasData) {
// Prep: Uplot Data Structure
let uplotData = [[],[]] // [X, Y1, Y2, ...]
// Iterate
canvasData.forEach( cd => {
if (Object.keys(cd).length == 4) { // MetricHisto Datafromat
uplotData[0].push(cd?.max ? cd.max : 0)
uplotData[1].push(cd.count)
} else { // Default
uplotData[0].push(cd.value)
uplotData[1].push(cd.count)
}
})
return uplotData
}
export function binsFromFootprint(weights, scope, values, numBins) {
let min = 0, max = 0 //, median = 0
if (values.length != 0) {
// Extreme, wrong peak vlaues: Filter here or backend?
// median = median(values)
for (let x of values) {
min = Math.min(min, x)
max = Math.max(max, x)
}
max += 1 // So that we have an exclusive range.
}
if (numBins == null || numBins < 3)
numBins = 3
let scopeWeights
switch (scope) {
case 'core':
scopeWeights = weights.coreHours
break
case 'accelerator':
scopeWeights = weights.accHours
break
default: // every other scope: use 'node'
scopeWeights = weights.nodeHours
}
const rawBins = new Array(numBins).fill(0)
for (let i = 0; i < values.length; i++)
rawBins[Math.floor(((values[i] - min) / (max - min)) * numBins)] += scopeWeights ? scopeWeights[i] : 1
const bins = rawBins.map((count, idx) => ({
value: Math.floor(min + ((idx + 1) / numBins) * (max - min)),
count: count
}))
return {
bins: bins
}
}
export function transformDataForRoofline(flopsAny, memBw) { // Uses Metric Objects: {series:[{},{},...], timestep:60, name:$NAME}
/* c will contain values from 0 to 1 representing the time */
let data = null
const x = [], y = [], c = []
if (flopsAny && memBw) {
const nodes = flopsAny.series.length
const timesteps = flopsAny.series[0].data.length
for (let i = 0; i < nodes; i++) {
const flopsData = flopsAny.series[i].data
const memBwData = memBw.series[i].data
for (let j = 0; j < timesteps; j++) {
const f = flopsData[j], m = memBwData[j]
const intensity = f / m
if (Number.isNaN(intensity) || !Number.isFinite(intensity))
continue
x.push(intensity)
y.push(f)
c.push(j / timesteps)
}
}
} else {
console.warn("transformData: metrics for 'mem_bw' and/or 'flops_any' missing!")
}
if (x.length > 0 && y.length > 0 && c.length > 0) {
data = [null, [x, y], c] // for dataformat see roofline.svelte
}
return data
}
// Return something to be plotted. The argument shall be the result of the
// `nodeMetrics` GraphQL query.
// Hardcoded metric names required for correct render
export function transformPerNodeDataForRoofline(nodes) {
let data = null
const x = [], y = []
for (let node of nodes) {
let flopsAny = node.metrics.find(m => m.name == 'flops_any' && m.scope == 'node')?.metric
let memBw = node.metrics.find(m => m.name == 'mem_bw' && m.scope == 'node')?.metric
if (!flopsAny || !memBw) {
console.warn("transformPerNodeData: metrics for 'mem_bw' and/or 'flops_any' missing!")
continue
}
let flopsData = flopsAny.series[0].data, memBwData = memBw.series[0].data
const f = flopsData[flopsData.length - 1], m = memBwData[flopsData.length - 1]
const intensity = f / m
if (Number.isNaN(intensity) || !Number.isFinite(intensity))
continue
x.push(intensity)
y.push(f)
}
if (x.length > 0 && y.length > 0) {
data = [null, [x, y], []] // for dataformat see roofline.svelte
}
return data
}
export async function fetchJwt(username) {
const raw = await fetch(`/frontend/jwt/?username=${username}`);
if (!raw.ok) {
const message = `An error has occured: ${response.status}`;
throw new Error(message);
}
const res = await raw.text();
return res;
}
// https://stackoverflow.com/questions/45309447/calculating-median-javascript
// function median(numbers) {
// const sorted = Array.from(numbers).sort((a, b) => a - b);
// const middle = Math.floor(sorted.length / 2);
// if (sorted.length % 2 === 0) {
// return (sorted[middle - 1] + sorted[middle]) / 2;
// }
// return sorted[middle];
// }