mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-07-26 22:26:08 +02:00
Restructure frontend svelte file src folder
- Goal: Dependency structure mirrored in file structure
This commit is contained in:
431
web/frontend/src/generic/Filters.svelte
Normal file
431
web/frontend/src/generic/Filters.svelte
Normal 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>
|
321
web/frontend/src/generic/JobList.svelte
Normal file
321
web/frontend/src/generic/JobList.svelte
Normal 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>
|
59
web/frontend/src/generic/PlotTable.svelte
Normal file
59
web/frontend/src/generic/PlotTable.svelte
Normal 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>
|
72
web/frontend/src/generic/cache-exchange.js
Normal file
72
web/frontend/src/generic/cache-exchange.js
Normal 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
|
||||
});
|
||||
})
|
||||
)
|
||||
]);
|
||||
};
|
||||
};
|
||||
|
109
web/frontend/src/generic/filters/Cluster.svelte
Normal file
109
web/frontend/src/generic/filters/Cluster.svelte
Normal 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>
|
258
web/frontend/src/generic/filters/Duration.svelte
Normal file
258
web/frontend/src/generic/filters/Duration.svelte
Normal 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>
|
19
web/frontend/src/generic/filters/InfoBox.svelte
Normal file
19
web/frontend/src/generic/filters/InfoBox.svelte
Normal 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>
|
91
web/frontend/src/generic/filters/JobStates.svelte
Normal file
91
web/frontend/src/generic/filters/JobStates.svelte
Normal 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>
|
259
web/frontend/src/generic/filters/Resources.svelte
Normal file
259
web/frontend/src/generic/filters/Resources.svelte
Normal 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>
|
134
web/frontend/src/generic/filters/StartTime.svelte
Normal file
134
web/frontend/src/generic/filters/StartTime.svelte
Normal 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>
|
95
web/frontend/src/generic/filters/Stats.svelte
Normal file
95
web/frontend/src/generic/filters/Stats.svelte
Normal 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>
|
101
web/frontend/src/generic/filters/Tags.svelte
Normal file
101
web/frontend/src/generic/filters/Tags.svelte
Normal 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>
|
228
web/frontend/src/generic/helper/JobFootprint.svelte
Normal file
228
web/frontend/src/generic/helper/JobFootprint.svelte
Normal 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> <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} <!-- 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>
|
54
web/frontend/src/generic/helper/Refresher.svelte
Normal file
54
web/frontend/src/generic/helper/Refresher.svelte
Normal 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>
|
||||
|
44
web/frontend/src/generic/helper/Tag.svelte
Normal file
44
web/frontend/src/generic/helper/Tag.svelte
Normal 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>
|
113
web/frontend/src/generic/helper/TextFilter.svelte
Normal file
113
web/frontend/src/generic/helper/TextFilter.svelte
Normal 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>
|
||||
|
133
web/frontend/src/generic/joblist/JobInfo.svelte
Normal file
133
web/frontend/src/generic/joblist/JobInfo.svelte
Normal 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>
|
188
web/frontend/src/generic/joblist/JobListRow.svelte
Normal file
188
web/frontend/src/generic/joblist/JobListRow.svelte
Normal 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>
|
231
web/frontend/src/generic/joblist/Pagination.svelte
Normal file
231
web/frontend/src/generic/joblist/Pagination.svelte
Normal 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>
|
235
web/frontend/src/generic/plots/Histogram.svelte
Normal file
235
web/frontend/src/generic/plots/Histogram.svelte
Normal 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}
|
516
web/frontend/src/generic/plots/MetricPlot.svelte
Normal file
516
web/frontend/src/generic/plots/MetricPlot.svelte
Normal 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>
|
95
web/frontend/src/generic/plots/Pie.svelte
Normal file
95
web/frontend/src/generic/plots/Pie.svelte
Normal 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>
|
124
web/frontend/src/generic/plots/Polar.svelte
Normal file
124
web/frontend/src/generic/plots/Polar.svelte
Normal 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>
|
372
web/frontend/src/generic/plots/Roofline.svelte
Normal file
372
web/frontend/src/generic/plots/Roofline.svelte
Normal 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}
|
||||
|
246
web/frontend/src/generic/plots/RooflineHeatmap.svelte
Normal file
246
web/frontend/src/generic/plots/RooflineHeatmap.svelte
Normal 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>
|
185
web/frontend/src/generic/plots/Scatter.svelte
Normal file
185
web/frontend/src/generic/plots/Scatter.svelte
Normal 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>
|
304
web/frontend/src/generic/select/DoubleRangeSlider.svelte
Normal file
304
web/frontend/src/generic/select/DoubleRangeSlider.svelte
Normal 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>
|
94
web/frontend/src/generic/select/HistogramSelection.svelte
Normal file
94
web/frontend/src/generic/select/HistogramSelection.svelte
Normal 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>
|
192
web/frontend/src/generic/select/MetricSelection.svelte
Normal file
192
web/frontend/src/generic/select/MetricSelection.svelte
Normal 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>
|
115
web/frontend/src/generic/select/SortSelection.svelte
Normal file
115
web/frontend/src/generic/select/SortSelection.svelte
Normal 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>
|
||||
|
96
web/frontend/src/generic/select/TimeSelection.svelte
Normal file
96
web/frontend/src/generic/select/TimeSelection.svelte
Normal 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>
|
34
web/frontend/src/generic/units.js
Normal file
34
web/frontend/src/generic/units.js
Normal 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);
|
539
web/frontend/src/generic/utils.js
Normal file
539
web/frontend/src/generic/utils.js
Normal 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];
|
||||
// }
|
Reference in New Issue
Block a user