mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-09-05 08:32:59 +02:00
Import svelte web frontend
This commit is contained in:
77
web/frontend/src/filters/Cluster.svelte
Normal file
77
web/frontend/src/filters/Cluster.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import { Button, ListGroup, ListGroupItem,
|
||||
Modal, ModalBody, ModalHeader, ModalFooter } from '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={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('update', { cluster, partition })
|
||||
}}>Close & Apply</Button>
|
||||
<Button color="danger" on:click={() => {
|
||||
isOpen = false
|
||||
cluster = pendingCluster = null
|
||||
partition = pendingPartition = null
|
||||
dispatch('update', { cluster, partition })
|
||||
}}>Reset</Button>
|
||||
<Button on:click={() => (isOpen = false)}>Close</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
302
web/frontend/src/filters/DoubleRangeSlider.svelte
Normal file
302
web/frontend/src/filters/DoubleRangeSlider.svelte
Normal file
@@ -0,0 +1,302 @@
|
||||
<!--
|
||||
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
|
||||
|
||||
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;
|
||||
|
||||
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 inputFieldFrom, inputFieldTo;
|
||||
|
||||
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.value != values[0].toString())
|
||||
inputFieldFrom.value = values[0].toString();
|
||||
if (values[1] != null && inputFieldTo.value != values[1].toString())
|
||||
inputFieldTo.value = 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 + event.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:this={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:this={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>
|
95
web/frontend/src/filters/Duration.svelte
Normal file
95
web/frontend/src/filters/Duration.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Row, Col, Button, Modal, ModalBody, ModalHeader, ModalFooter, FormGroup } from 'sveltestrap'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let isOpen = false
|
||||
export let from = null
|
||||
export let to = null
|
||||
|
||||
let pendingFrom, pendingTo
|
||||
|
||||
function reset() {
|
||||
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
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal isOpen={isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||
<ModalHeader>
|
||||
Select Start Time
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<h4>Between</h4>
|
||||
<Row>
|
||||
<Col>
|
||||
<div class="input-group mb-2 mr-sm-2">
|
||||
<input type="number" class="form-control" bind:value={pendingFrom.hours}>
|
||||
<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" class="form-control" bind:value={pendingFrom.mins}>
|
||||
<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" class="form-control" bind:value={pendingTo.hours}>
|
||||
<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" class="form-control" bind:value={pendingTo.mins}>
|
||||
<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
|
||||
from = hoursAndMinsToSecs(pendingFrom)
|
||||
to = hoursAndMinsToSecs(pendingTo)
|
||||
dispatch('update', { from, to })
|
||||
}}>
|
||||
Close & Apply
|
||||
</Button>
|
||||
<Button color="danger" on:click={() => {
|
||||
isOpen = false
|
||||
from = null
|
||||
to = null
|
||||
reset()
|
||||
dispatch('update', { from, to })
|
||||
}}>Reset</Button>
|
||||
<Button on:click={() => (isOpen = false)}>Close</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
323
web/frontend/src/filters/Filters.svelte
Normal file
323
web/frontend/src/filters/Filters.svelte
Normal file
@@ -0,0 +1,323 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
Properties:
|
||||
- menuText: String? (Optional text to show in the dropdown menu)
|
||||
- filterPresets: Object? (Optional predefined filter values)
|
||||
Events:
|
||||
- 'update': The detail's 'filters' prop are new filter items to be applied
|
||||
Functions:
|
||||
- void update(additionalFilters: Object?): Triggers an update
|
||||
-->
|
||||
<script>
|
||||
import { Row, Col, DropdownItem, DropdownMenu,
|
||||
DropdownToggle, ButtonDropdown, Icon } from 'sveltestrap'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Info from './InfoBox.svelte'
|
||||
import Cluster from './Cluster.svelte'
|
||||
import JobStates, { allJobStates } from './JobStates.svelte'
|
||||
import StartTime from './StartTime.svelte'
|
||||
import Tags from './Tags.svelte'
|
||||
import Tag from '../Tag.svelte'
|
||||
import Duration from './Duration.svelte'
|
||||
import Resources from './Resources.svelte'
|
||||
import Statistics from './Stats.svelte'
|
||||
// import TimeSelection from './TimeSelection.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',
|
||||
|
||||
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 || { from: null, to: null },
|
||||
jobId: filterPresets.jobId || '',
|
||||
arrayJobId: filterPresets.arrayJobId || null,
|
||||
user: filterPresets.user || '',
|
||||
project: filterPresets.project || '',
|
||||
|
||||
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
|
||||
|
||||
// Can be called from the outside to trigger a 'update' event from this component.
|
||||
export function update(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.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.jobId)
|
||||
items.push({ jobId: { eq: 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 } })
|
||||
for (let stat of filters.stats)
|
||||
items.push({ [stat.field]: { from: stat.from, to: stat.to } })
|
||||
|
||||
dispatch('update', { 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.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)
|
||||
opts.push(`startTime=${dateToUnixEpoch(filters.startTime.from)}-${dateToUnixEpoch(filters.startTime.to)}`)
|
||||
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.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)
|
||||
opts.push(`user=${filters.user}`)
|
||||
if (filters.userMatch != 'contains')
|
||||
opts.push(`userMatch=${filters.userMatch}`)
|
||||
if (filters.project)
|
||||
opts.push(`project=${filters.project}`)
|
||||
if (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"/> Nodes/Accelerators
|
||||
</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', seconds: 6*60*60 },
|
||||
{ text: 'Last 12hrs', seconds: 12*60*60 },
|
||||
{ text: 'Last 24hrs', seconds: 24*60*60 },
|
||||
{ text: 'Last 48hrs', seconds: 48*60*60 },
|
||||
{ text: 'Last 7 days', seconds: 7*24*60*60 },
|
||||
{ text: 'Last 30 days', seconds: 30*24*60*60 }
|
||||
] as {text, seconds}}
|
||||
<DropdownItem on:click={() => {
|
||||
filters.startTime.from = (new Date(Date.now() - seconds * 1000)).toISOString()
|
||||
filters.startTime.to = (new Date(Date.now())).toISOString()
|
||||
update()
|
||||
}}>
|
||||
<Icon name="calendar-range"/> {text}
|
||||
</DropdownItem>
|
||||
{/each}
|
||||
{/if}
|
||||
</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
</Col>
|
||||
<!-- {#if startTimeQuickSelect}
|
||||
<Col xs="auto">
|
||||
<TimeSelection customEnabled={false} anyEnabled={true}
|
||||
from={filters.startTime.from ? new Date(filters.startTime.from) : null}
|
||||
to={filters.startTime.to ? new Date(filters.startTime.to) : null}
|
||||
options={{
|
||||
'Last 6hrs': 6*60*60,
|
||||
'Last 12hrs': 12*60*60,
|
||||
'Last 24hrs': 24*60*60,
|
||||
'Last 48hrs': 48*60*60,
|
||||
'Last 7 days': 7*24*60*60,
|
||||
'Last 30 days': 30*24*60*60}}
|
||||
on:change={({ detail: { from, to } }) => {
|
||||
filters.startTime.from = from?.toISOString()
|
||||
filters.startTime.to = to?.toISOString()
|
||||
console.log(filters.startTime)
|
||||
update()
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
{/if} -->
|
||||
<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)}>
|
||||
{new Date(filters.startTime.from).toLocaleString()} - {new Date(filters.startTime.to).toLocaleString()}
|
||||
</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.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}
|
||||
<Info icon="hdd-stack" on:click={() => (isResourcesOpen = true)}>
|
||||
Nodes: {filters.numNodes.from} - {filters.numNodes.to}
|
||||
</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={disableClusterSelection}
|
||||
bind:isOpen={isClusterOpen}
|
||||
bind:cluster={filters.cluster}
|
||||
bind:partition={filters.partition}
|
||||
on:update={() => update()} />
|
||||
|
||||
<JobStates
|
||||
bind:isOpen={isJobStatesOpen}
|
||||
bind:states={filters.states}
|
||||
on:update={() => update()} />
|
||||
|
||||
<StartTime
|
||||
bind:isOpen={isStartTimeOpen}
|
||||
bind:from={filters.startTime.from}
|
||||
bind:to={filters.startTime.to}
|
||||
on:update={() => update()} />
|
||||
|
||||
<Duration
|
||||
bind:isOpen={isDurationOpen}
|
||||
bind:from={filters.duration.from}
|
||||
bind:to={filters.duration.to}
|
||||
on:update={() => update()} />
|
||||
|
||||
<Tags
|
||||
bind:isOpen={isTagsOpen}
|
||||
bind:tags={filters.tags}
|
||||
on:update={() => update()} />
|
||||
|
||||
<Resources cluster={filters.cluster}
|
||||
bind:isOpen={isResourcesOpen}
|
||||
bind:numNodes={filters.numNodes}
|
||||
bind:numHWThreads={filters.numHWThreads}
|
||||
bind:numAccelerators={filters.numAccelerators}
|
||||
on:update={() => update()} />
|
||||
|
||||
<Statistics cluster={filters.cluster}
|
||||
bind:isOpen={isStatsOpen}
|
||||
bind:stats={filters.stats}
|
||||
on:update={() => update()} />
|
||||
|
||||
<style>
|
||||
:global(.cc-dropdown-on-hover:hover .dropdown-menu) {
|
||||
display: block;
|
||||
margin-top: 0px;
|
||||
padding-top: 0px;
|
||||
transform: none !important;
|
||||
}
|
||||
</style>
|
11
web/frontend/src/filters/InfoBox.svelte
Normal file
11
web/frontend/src/filters/InfoBox.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
import { Button, Icon } from 'sveltestrap'
|
||||
|
||||
export let icon
|
||||
export let modified = false
|
||||
</script>
|
||||
|
||||
<Button outline color={modified ? 'warning' : 'primary'} on:click>
|
||||
<Icon name={icon}/>
|
||||
<slot />
|
||||
</Button>
|
47
web/frontend/src/filters/JobStates.svelte
Normal file
47
web/frontend/src/filters/JobStates.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<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'
|
||||
|
||||
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={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('update', { states })
|
||||
}}>Close & Apply</Button>
|
||||
<Button color="danger" on:click={() => {
|
||||
isOpen = false
|
||||
states = [...allJobStates]
|
||||
pendingStates = [...allJobStates]
|
||||
dispatch('update', { states })
|
||||
}}>Reset</Button>
|
||||
<Button on:click={() => (isOpen = false)}>Close</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
99
web/frontend/src/filters/Resources.svelte
Normal file
99
web/frontend/src/filters/Resources.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import { Button, Modal, ModalBody, ModalHeader, ModalFooter } from 'sveltestrap'
|
||||
import DoubleRangeSlider from './DoubleRangeSlider.svelte'
|
||||
|
||||
const clusters = getContext('clusters'),
|
||||
initialized = getContext('initialized'),
|
||||
dispatch = createEventDispatcher()
|
||||
|
||||
export let cluster = null
|
||||
export let isModified = false
|
||||
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 }
|
||||
|
||||
let pendingNumNodes = numNodes, pendingNumHWThreads = numHWThreads, pendingNumAccelerators = numAccelerators
|
||||
$: isModified = pendingNumNodes.from != numNodes.from || pendingNumNodes.to != numNodes.to
|
||||
|| pendingNumHWThreads.from != numHWThreads.from || pendingNumHWThreads.to != numHWThreads.to
|
||||
|| pendingNumAccelerators.from != numAccelerators.from || pendingNumAccelerators.to != numAccelerators.to
|
||||
|
||||
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)
|
||||
|
||||
let minNumNodes = 1, maxNumNodes = 0, minNumHWThreads = 1, maxNumHWThreads = 0, minNumAccelerators = 0, maxNumAccelerators = 0
|
||||
$: {
|
||||
if ($initialized) {
|
||||
if (cluster != null) {
|
||||
const { filterRanges, subClusters } = clusters.find(c => c.name == cluster)
|
||||
minNumNodes = filterRanges.numNodes.from
|
||||
maxNumNodes = filterRanges.numNodes.to
|
||||
maxNumAccelerators = findMaxNumAccels([{ subClusters }])
|
||||
} else if (clusters.length > 0) {
|
||||
const { filterRanges } = clusters[0]
|
||||
minNumNodes = filterRanges.numNodes.from
|
||||
maxNumNodes = filterRanges.numNodes.to
|
||||
maxNumAccelerators = findMaxNumAccels(clusters)
|
||||
for (let cluster of 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 }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal isOpen={isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||
<ModalHeader>
|
||||
Select Number of Nodes, HWThreads and Accelerators
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<h4>Number of Nodes</h4>
|
||||
<DoubleRangeSlider
|
||||
on:change={({ detail }) => (pendingNumNodes = { from: detail[0], to: detail[1] })}
|
||||
min={minNumNodes} max={maxNumNodes}
|
||||
firstSlider={pendingNumNodes.from} secondSlider={pendingNumNodes.to} />
|
||||
<!-- <DoubleRangeSlider
|
||||
on:change={({ detail }) => (pendingNumHWThreads = { from: detail[0], to: detail[1] })}
|
||||
min={minNumHWThreads} max={maxNumHWThreads}
|
||||
firstSlider={pendingNumHWThreads.from} secondSlider={pendingNumHWThreads.to} /> -->
|
||||
{#if maxNumAccelerators != null && maxNumAccelerators > 1}
|
||||
<DoubleRangeSlider
|
||||
on:change={({ detail }) => (pendingNumAccelerators = { from: detail[0], to: detail[1] })}
|
||||
min={minNumAccelerators} max={maxNumAccelerators}
|
||||
firstSlider={pendingNumAccelerators.from} secondSlider={pendingNumAccelerators.to} />
|
||||
{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary"
|
||||
disabled={pendingNumNodes.from == null || pendingNumNodes.to == null}
|
||||
on:click={() => {
|
||||
isOpen = false
|
||||
numNodes = { from: pendingNumNodes.from, to: pendingNumNodes.to }
|
||||
numHWThreads = { from: pendingNumHWThreads.from, to: pendingNumHWThreads.to }
|
||||
numAccelerators = { from: pendingNumAccelerators.from, to: pendingNumAccelerators.to }
|
||||
dispatch('update', { numNodes, numHWThreads, numAccelerators })
|
||||
}}>
|
||||
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 }
|
||||
numNodes = { from: pendingNumNodes.from, to: pendingNumNodes.to }
|
||||
numHWThreads = { from: pendingNumHWThreads.from, to: pendingNumHWThreads.to }
|
||||
numAccelerators = { from: pendingNumAccelerators.from, to: pendingNumAccelerators.to }
|
||||
dispatch('update', { numNodes, numHWThreads, numAccelerators })
|
||||
}}>Reset</Button>
|
||||
<Button on:click={() => (isOpen = false)}>Close</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
90
web/frontend/src/filters/StartTime.svelte
Normal file
90
web/frontend/src/filters/StartTime.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import { Row, Button, Input, Modal, ModalBody, ModalHeader, ModalFooter, FormGroup } from 'sveltestrap'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let isModified = false
|
||||
export let isOpen = false
|
||||
export let from = null
|
||||
export let to = null
|
||||
|
||||
let pendingFrom, pendingTo
|
||||
|
||||
function reset() {
|
||||
pendingFrom = from == null ? { date: '0000-00-00', time: '00:00' } : fromRFC3339(from)
|
||||
pendingTo = to == null ? { date: '0000-00-00', time: '00:00' } : fromRFC3339(to)
|
||||
}
|
||||
|
||||
reset()
|
||||
|
||||
function toRFC3339({ date, time }, secs = 0) {
|
||||
const dparts = date.split('-')
|
||||
const tparts = time.split(':')
|
||||
const d = new Date(
|
||||
Number.parseInt(dparts[0]),
|
||||
Number.parseInt(dparts[1]) - 1,
|
||||
Number.parseInt(dparts[2]),
|
||||
Number.parseInt(tparts[0]),
|
||||
Number.parseInt(tparts[1]), secs)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function fromRFC3339(rfc3339) {
|
||||
const d = new Date(rfc3339)
|
||||
const pad = (n) => n.toString().padStart(2, '0')
|
||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
return { date, time }
|
||||
}
|
||||
|
||||
$: 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={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('update', { from, to })
|
||||
}}>
|
||||
Close & Apply
|
||||
</Button>
|
||||
<Button color="danger" on:click={() => {
|
||||
isOpen = false
|
||||
from = null
|
||||
to = null
|
||||
reset()
|
||||
dispatch('update', { from, to })
|
||||
}}>Reset</Button>
|
||||
<Button on:click={() => (isOpen = false)}>Close</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
113
web/frontend/src/filters/Stats.svelte
Normal file
113
web/frontend/src/filters/Stats.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import { Button, Modal, ModalBody, ModalHeader, ModalFooter } from 'sveltestrap'
|
||||
import DoubleRangeSlider from './DoubleRangeSlider.svelte'
|
||||
|
||||
const clusters = getContext('clusters'),
|
||||
initialized = getContext('initialized'),
|
||||
dispatch = createEventDispatcher()
|
||||
|
||||
export let cluster = null
|
||||
export let isModified = false
|
||||
export let isOpen = false
|
||||
export let stats = []
|
||||
|
||||
let statistics = [
|
||||
{
|
||||
field: 'flopsAnyAvg',
|
||||
text: 'FLOPs (Avg.)',
|
||||
metric: 'flops_any',
|
||||
from: 0, to: 0, peak: 0,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
field: 'memBwAvg',
|
||||
text: 'Mem. Bw. (Avg.)',
|
||||
metric: 'mem_bw',
|
||||
from: 0, to: 0, peak: 0,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
field: 'loadAvg',
|
||||
text: 'Load (Avg.)',
|
||||
metric: 'cpu_load',
|
||||
from: 0, to: 0, peak: 0,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
field: 'memUsedMax',
|
||||
text: 'Mem. used (Max.)',
|
||||
metric: 'mem_used',
|
||||
from: 0, to: 0, peak: 0,
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
|
||||
$: 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
|
||||
})
|
||||
|
||||
function getPeak(cluster, metric) {
|
||||
const mc = cluster.metricConfig.find(mc => mc.name == metric)
|
||||
return mc ? mc.peak : 0
|
||||
}
|
||||
|
||||
function resetRange(isInitialized, cluster) {
|
||||
if (!isInitialized)
|
||||
return
|
||||
|
||||
if (cluster != null) {
|
||||
let c = clusters.find(c => c.name == cluster)
|
||||
for (let stat of statistics) {
|
||||
stat.peak = getPeak(c, stat.metric)
|
||||
stat.from = 0
|
||||
stat.to = stat.peak
|
||||
}
|
||||
} else {
|
||||
for (let stat of statistics) {
|
||||
for (let c of clusters) {
|
||||
stat.peak = Math.max(stat.peak, getPeak(c, stat.metric))
|
||||
}
|
||||
stat.from = 0
|
||||
stat.to = stat.peak
|
||||
}
|
||||
}
|
||||
|
||||
statistics = [...statistics]
|
||||
}
|
||||
|
||||
$: resetRange($initialized, cluster)
|
||||
</script>
|
||||
|
||||
<Modal isOpen={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} />
|
||||
{/each}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" on:click={() => {
|
||||
isOpen = false
|
||||
stats = statistics.filter(stat => stat.enabled)
|
||||
dispatch('update', { stats })
|
||||
}}>Close & Apply</Button>
|
||||
<Button color="danger" on:click={() => {
|
||||
isOpen = false
|
||||
statistics.forEach(stat => stat.enabled = false)
|
||||
stats = []
|
||||
dispatch('update', { stats })
|
||||
}}>Reset</Button>
|
||||
<Button on:click={() => (isOpen = false)}>Close</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
67
web/frontend/src/filters/Tags.svelte
Normal file
67
web/frontend/src/filters/Tags.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import { Button, ListGroup, ListGroupItem, Input,
|
||||
Modal, ModalBody, ModalHeader, ModalFooter, Icon } from 'sveltestrap'
|
||||
import { fuzzySearchTags } from '../utils.js'
|
||||
import Tag from '../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={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={tag} />
|
||||
</ListGroupItem>
|
||||
{:else}
|
||||
<ListGroupItem disabled>No Tags</ListGroupItem>
|
||||
{/each}
|
||||
{/if}
|
||||
</ListGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" on:click={() => {
|
||||
isOpen = false
|
||||
tags = [...pendingTags]
|
||||
dispatch('update', { tags })
|
||||
}}>Close & Apply</Button>
|
||||
<Button color="danger" on:click={() => {
|
||||
isOpen = false
|
||||
tags = []
|
||||
pendingTags = []
|
||||
dispatch('update', { tags })
|
||||
}}>Reset</Button>
|
||||
<Button on:click={() => (isOpen = false)}>Close</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
80
web/frontend/src/filters/TimeSelection.svelte
Normal file
80
web/frontend/src/filters/TimeSelection.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script>
|
||||
import { Icon, Input, InputGroup, InputGroupText } from 'sveltestrap'
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let from
|
||||
export let to
|
||||
export let customEnabled = true
|
||||
export let anyEnabled = false
|
||||
export let options = {
|
||||
'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 = to && from
|
||||
? (to.getTime() - from.getTime()) / 1000
|
||||
: (anyEnabled ? -2 : -1)
|
||||
|
||||
function updateTimeRange(event) {
|
||||
if (timeRange == -1) {
|
||||
pendingFrom = null
|
||||
pendingTo = null
|
||||
return
|
||||
}
|
||||
if (timeRange == -2) {
|
||||
from = pendingFrom = null
|
||||
to = pendingTo = null
|
||||
dispatch('change', { from, to })
|
||||
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>
|
||||
<!-- <InputGroupText>
|
||||
Time
|
||||
</InputGroupText> -->
|
||||
<select class="form-select" bind:value={timeRange} on:change={updateTimeRange}>
|
||||
{#if customEnabled}
|
||||
<option value={-1}>Custom</option>
|
||||
{/if}
|
||||
{#if anyEnabled}
|
||||
<option value={-2}>Any</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>
|
51
web/frontend/src/filters/UserOrProject.svelte
Normal file
51
web/frontend/src/filters/UserOrProject.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script>
|
||||
import { InputGroup, Input } from 'sveltestrap'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let user = ''
|
||||
export let project = ''
|
||||
let mode = 'user', term = ''
|
||||
const throttle = 500
|
||||
|
||||
function modeChanged() {
|
||||
if (mode == 'user') {
|
||||
project = term
|
||||
term = user
|
||||
} else {
|
||||
user = term
|
||||
term = project
|
||||
}
|
||||
termChanged(0)
|
||||
}
|
||||
|
||||
let timeoutId = null
|
||||
function termChanged(sleep = throttle) {
|
||||
if (mode == 'user')
|
||||
user = term
|
||||
else
|
||||
project = term
|
||||
|
||||
if (timeoutId != null)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
dispatch('update', {
|
||||
user,
|
||||
project
|
||||
})
|
||||
}, sleep)
|
||||
}
|
||||
</script>
|
||||
|
||||
<InputGroup>
|
||||
<select style="max-width: 175px;" class="form-select"
|
||||
bind:value={mode} on:change={modeChanged}>
|
||||
<option value={'user'}>Search User</option>
|
||||
<option value={'project'}>Search Project</option>
|
||||
</select>
|
||||
<Input
|
||||
type="text" bind:value={term} on:change={() => termChanged()} on:keyup={(event) => termChanged(event.key == 'Enter' ? 0 : throttle)}
|
||||
placeholder={mode == 'user' ? 'filter username...' : 'filter project...'} />
|
||||
</InputGroup>
|
Reference in New Issue
Block a user