Import svelte web frontend

This commit is contained in:
Jan Eitzinger
2022-06-22 11:20:57 +02:00
parent 9217780760
commit 68d1f5fc3f
60 changed files with 6661 additions and 0 deletions

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

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

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

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

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

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