mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-07-26 14:16:07 +02:00
Import svelte web frontend
This commit is contained in:
88
web/frontend/src/joblist/JobInfo.svelte
Normal file
88
web/frontend/src/joblist/JobInfo.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
Properties:
|
||||
- job: GraphQL.Job
|
||||
- jobTags: Defaults to job.tags, usefull for dynamically updating the tags.
|
||||
-->
|
||||
<script context="module">
|
||||
export const scrambleNames = window.localStorage.getItem("cc-scramble-names")
|
||||
export const scramble = (str) => [...str].reduce((x, c, i) => x * 7 + c.charCodeAt(0) * i * 21, 5).toString(32)
|
||||
</script>
|
||||
<script>
|
||||
import Tag from '../Tag.svelte';
|
||||
import { Badge, Icon } from 'sveltestrap';
|
||||
|
||||
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)}`;
|
||||
}
|
||||
</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/>
|
||||
{job.metaData.jobName}
|
||||
{/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"/> {job.project}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{job.numNodes} <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}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Start: <span class="fw-bold">{(new Date(job.startTime)).toLocaleString()}</span>
|
||||
<br/>
|
||||
Duration: <span class="fw-bold">{formatDuration(job.duration)}</span>
|
||||
{#if job.state == 'running'}
|
||||
<Badge color="success">running</Badge>
|
||||
{:else if job.state != 'completed'}
|
||||
<Badge color="danger">{job.state}</Badge>
|
||||
{/if}
|
||||
{#if job.walltime}
|
||||
<br/>
|
||||
Walltime: <span class="fw-bold">{formatDuration(job.walltime)}</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{#each jobTags as tag}
|
||||
<Tag tag={tag}/>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
190
web/frontend/src/joblist/JobList.svelte
Normal file
190
web/frontend/src/joblist/JobList.svelte
Normal file
@@ -0,0 +1,190 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
Properties:
|
||||
- metrics: [String] (can change from outside)
|
||||
- sorting: { field: String, order: "DESC" | "ASC" } (can change from outside)
|
||||
- matchedJobs: Number (changes from inside)
|
||||
Functions:
|
||||
- update(filters?: [JobFilter])
|
||||
-->
|
||||
<script>
|
||||
import { operationStore, query, mutation } from '@urql/svelte'
|
||||
import { getContext } from 'svelte';
|
||||
import { Row, Table, Card, Spinner } from 'sveltestrap'
|
||||
import Pagination from './Pagination.svelte'
|
||||
import JobListRow from './Row.svelte'
|
||||
import { stickyHeader } from '../utils.js'
|
||||
|
||||
const ccconfig = getContext('cc-config'),
|
||||
clusters = getContext('clusters'),
|
||||
initialized = getContext('initialized')
|
||||
|
||||
export let sorting = { field: "startTime", order: "DESC" }
|
||||
export let matchedJobs = 0
|
||||
export let metrics = ccconfig.plot_list_selectedMetrics
|
||||
|
||||
let itemsPerPage = ccconfig.plot_list_jobsPerPage
|
||||
let page = 1
|
||||
let paging = { itemsPerPage, page }
|
||||
|
||||
const jobs = operationStore(`
|
||||
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,
|
||||
SMT, exclusive, partition, arrayJobId,
|
||||
monitoringStatus, state,
|
||||
tags { id, type, name }
|
||||
userData { name }
|
||||
metaData
|
||||
}
|
||||
count
|
||||
}
|
||||
}`, {
|
||||
paging,
|
||||
sorting,
|
||||
filter: []
|
||||
}, {
|
||||
pause: true
|
||||
})
|
||||
|
||||
const updateConfiguration = mutation({
|
||||
query: `mutation($name: String!, $value: String!) {
|
||||
updateConfiguration(name: $name, value: $value)
|
||||
}`
|
||||
})
|
||||
|
||||
$: $jobs.variables = { ...$jobs.variables, sorting, paging }
|
||||
$: matchedJobs = $jobs.data != null ? $jobs.data.jobs.count : 0
|
||||
|
||||
// (Re-)query and optionally set new filters.
|
||||
export function update(filters) {
|
||||
if (filters != null) {
|
||||
let minRunningFor = ccconfig.plot_list_hideShortRunningJobs
|
||||
if (minRunningFor && minRunningFor > 0) {
|
||||
filters.push({ minRunningFor })
|
||||
}
|
||||
|
||||
$jobs.variables.filter = filters
|
||||
console.log('filters:', ...filters.map(f => Object.entries(f)).flat(2))
|
||||
}
|
||||
|
||||
page = 1
|
||||
$jobs.variables.paging = paging = { page, itemsPerPage };
|
||||
$jobs.context.pause = false
|
||||
$jobs.reexecute({ requestPolicy: 'network-only' })
|
||||
}
|
||||
|
||||
query(jobs)
|
||||
|
||||
let tableWidth = null
|
||||
let jobInfoColumnWidth = 250
|
||||
$: 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>
|
||||
{#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}
|
||||
({clusters
|
||||
.map(cluster => cluster.metricConfig.find(m => m.name == metric))
|
||||
.filter(m => m != null).map(m => m.unit)
|
||||
.reduce((arr, unit) => arr.includes(unit) ? arr : [...arr, unit], [])
|
||||
.join(', ')})
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $jobs.error}
|
||||
<tr>
|
||||
<td colspan="{metrics.length + 1}">
|
||||
<Card body color="danger" class="mb-3"><h2>{$jobs.error.message}</h2></Card>
|
||||
</td>
|
||||
</tr>
|
||||
{:else if $jobs.fetching || !$jobs.data}
|
||||
<tr>
|
||||
<td colspan="{metrics.length + 1}">
|
||||
<Spinner secondary />
|
||||
</td>
|
||||
</tr>
|
||||
{:else if $jobs.data && $initialized}
|
||||
{#each $jobs.data.jobs.items as job (job)}
|
||||
<JobListRow
|
||||
job={job}
|
||||
metrics={metrics}
|
||||
plotWidth={plotWidth} />
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="{metrics.length + 1}">
|
||||
No jobs found
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Pagination
|
||||
bind:page={page}
|
||||
{itemsPerPage}
|
||||
itemText="Jobs"
|
||||
totalItems={matchedJobs}
|
||||
on:update={({ detail }) => {
|
||||
if (detail.itemsPerPage != itemsPerPage) {
|
||||
itemsPerPage = detail.itemsPerPage
|
||||
updateConfiguration({
|
||||
name: "plot_list_jobsPerPage",
|
||||
value: itemsPerPage.toString()
|
||||
}).then(res => {
|
||||
if (res.error)
|
||||
console.error(res.error);
|
||||
})
|
||||
}
|
||||
|
||||
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page }
|
||||
}} />
|
||||
|
||||
<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>
|
230
web/frontend/src/joblist/Pagination.svelte
Normal file
230
web/frontend/src/joblist/Pagination.svelte
Normal file
@@ -0,0 +1,230 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
Properties:
|
||||
- page: Number (changes from inside)
|
||||
- itemsPerPage: Number (changes from inside)
|
||||
- totalItems: Number (only displayed)
|
||||
Events:
|
||||
- "update": { 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", { 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>
|
43
web/frontend/src/joblist/Refresher.svelte
Normal file
43
web/frontend/src/joblist/Refresher.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
Events:
|
||||
- 'reload': When fired, the parent component shoud refresh its contents
|
||||
-->
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Button, Icon, InputGroup } from 'sveltestrap'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let refreshInterval = null;
|
||||
let refreshIntervalId = null;
|
||||
function refreshIntervalChanged() {
|
||||
if (refreshIntervalId != null)
|
||||
clearInterval(refreshIntervalId);
|
||||
|
||||
if (refreshInterval == null)
|
||||
return;
|
||||
|
||||
refreshIntervalId = setInterval(() => dispatch("reload"), refreshInterval);
|
||||
}
|
||||
|
||||
export let initially = null
|
||||
if (initially != null) {
|
||||
refreshInterval = initially * 1000
|
||||
refreshIntervalChanged()
|
||||
}
|
||||
</script>
|
||||
|
||||
<InputGroup>
|
||||
<Button outline on:click={() => dispatch("reload")} disabled={refreshInterval != null}>
|
||||
<Icon name="arrow-clockwise" /> Reload
|
||||
</Button>
|
||||
<select class="form-select" bind:value={refreshInterval} on:change={refreshIntervalChanged}>
|
||||
<option value={null}>No periodic reload</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>
|
101
web/frontend/src/joblist/Row.svelte
Normal file
101
web/frontend/src/joblist/Row.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
Properties:
|
||||
- job: GraphQL.Job (constant/key)
|
||||
- metrics: [String] (can change)
|
||||
- plotWidth: Number
|
||||
- plotHeight: Number
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { operationStore, query } from '@urql/svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import { Card, Spinner } from 'sveltestrap'
|
||||
import MetricPlot from '../plots/MetricPlot.svelte'
|
||||
import JobInfo from './JobInfo.svelte'
|
||||
import { maxScope } from '../utils.js'
|
||||
|
||||
export let job
|
||||
export let metrics
|
||||
export let plotWidth
|
||||
export let plotHeight = 275
|
||||
|
||||
let scopes = [job.numNodes == 1 ? 'core' : 'node']
|
||||
|
||||
const cluster = getContext('clusters').find(c => c.name == job.cluster)
|
||||
|
||||
const metricsQuery = operationStore(`query($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!) {
|
||||
jobMetrics(id: $id, metrics: $metrics, scopes: $scopes) {
|
||||
name
|
||||
metric {
|
||||
unit, scope, timestep
|
||||
statisticsSeries { min, mean, max }
|
||||
series {
|
||||
hostname, id, data
|
||||
statistics { min, avg, max }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, {
|
||||
id: job.id,
|
||||
metrics,
|
||||
scopes
|
||||
})
|
||||
|
||||
const selectScope = (jobMetrics) => jobMetrics.reduce(
|
||||
(a, b) => maxScope([a.metric.scope, b.metric.scope]) == a.metric.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 => jobMetrics.length > 0 ? selectScope(jobMetrics) : null)
|
||||
|
||||
$: metricsQuery.variables = { id: job.id, metrics, scopes }
|
||||
|
||||
if (job.monitoringStatus)
|
||||
query(metricsQuery)
|
||||
</script>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<JobInfo job={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}
|
||||
{#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric || i)}
|
||||
<td>
|
||||
{#if metric != null}
|
||||
<MetricPlot
|
||||
width={plotWidth}
|
||||
height={plotHeight}
|
||||
timestep={metric.metric.timestep}
|
||||
scope={metric.metric.scope}
|
||||
series={metric.metric.series}
|
||||
statisticsSeries={metric.metric.statisticsSeries}
|
||||
metric={metric.name}
|
||||
cluster={cluster}
|
||||
subCluster={job.subCluster} />
|
||||
{:else}
|
||||
<Card body color="warning">Missing Data</Card>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{/if}
|
||||
</tr>
|
71
web/frontend/src/joblist/SortSelection.svelte
Normal file
71
web/frontend/src/joblist/SortSelection.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
Properties:
|
||||
- sorting: { field: String, order: "DESC" | "ASC" } (changes from inside)
|
||||
- isOpen: Boolean (can change from inside and outside)
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { Icon, Button, ListGroup, ListGroupItem,
|
||||
Modal, ModalBody, ModalHeader, ModalFooter } from 'sveltestrap'
|
||||
|
||||
export let isOpen = false
|
||||
export let sorting = { field: 'startTime', order: 'DESC' }
|
||||
|
||||
let sortableColumns = [
|
||||
{ field: 'startTime', text: 'Start Time', order: 'DESC' },
|
||||
{ field: 'duration', text: 'Duration', order: 'DESC' },
|
||||
{ field: 'numNodes', text: 'Number of Nodes', order: 'DESC' },
|
||||
{ field: 'memUsedMax', text: 'Max. Memory Used', order: 'DESC' },
|
||||
{ field: 'flopsAnyAvg', text: 'Avg. FLOPs', order: 'DESC' },
|
||||
{ field: 'memBwAvg', text: 'Avg. Memory Bandwidth', order: 'DESC' },
|
||||
{ field: 'netBwAvg', text: 'Avg. Network Bandwidth', order: 'DESC' }
|
||||
]
|
||||
|
||||
let activeColumnIdx = sortableColumns.findIndex(col => col.field == sorting.field)
|
||||
sortableColumns[activeColumnIdx].order = sorting.order
|
||||
</script>
|
||||
|
||||
<Modal isOpen={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, 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>
|
Reference in New Issue
Block a user