mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2024-12-26 05:19:05 +01:00
Import svelte web frontend
This commit is contained in:
parent
9217780760
commit
68d1f5fc3f
31
web/frontend/README.md
Normal file
31
web/frontend/README.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# cc-svelte-datatable
|
||||||
|
|
||||||
|
[![Build](https://github.com/ClusterCockpit/cc-svelte-datatable/actions/workflows/build.yml/badge.svg)](https://github.com/ClusterCockpit/cc-svelte-datatable/actions/workflows/build.yml)
|
||||||
|
|
||||||
|
A frontend for [ClusterCockpit](https://github.com/ClusterCockpit/ClusterCockpit) and [cc-backend](https://github.com/ClusterCockpit/cc-backend). Backend specific configuration can de done using the constants defined in the `intro` section in `./rollup.config.js`.
|
||||||
|
|
||||||
|
Builds on:
|
||||||
|
* [Svelte](https://svelte.dev/)
|
||||||
|
* [SvelteStrap](https://sveltestrap.js.org/)
|
||||||
|
* [Bootstrap 5](https://getbootstrap.com/)
|
||||||
|
* [urql](https://github.com/FormidableLabs/urql)
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
[Yarn](https://yarnpkg.com/) is recommended for package management.
|
||||||
|
Due to an issue with Yarn v2 you have to stick to Yarn v1.
|
||||||
|
|
||||||
|
Install the dependencies...
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
...then start [Rollup](https://rollupjs.org):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit a component file in `src`, save it, and reload the page to see your changes.
|
||||||
|
|
25
web/frontend/package.json
Normal file
25
web/frontend/package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "svelte-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rollup -c",
|
||||||
|
"dev": "rollup -c -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-commonjs": "^17.0.0",
|
||||||
|
"@rollup/plugin-node-resolve": "^11.0.0",
|
||||||
|
"rollup": "^2.3.4",
|
||||||
|
"rollup-plugin-css-only": "^3.1.0",
|
||||||
|
"rollup-plugin-svelte": "^7.0.0",
|
||||||
|
"rollup-plugin-terser": "^7.0.0",
|
||||||
|
"svelte": "^3.42.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/plugin-replace": "^2.4.1",
|
||||||
|
"@urql/svelte": "^1.3.0",
|
||||||
|
"graphql": "^15.6.0",
|
||||||
|
"sveltestrap": "^5.6.1",
|
||||||
|
"uplot": "^1.6.7",
|
||||||
|
"wonka": "^4.0.15"
|
||||||
|
}
|
||||||
|
}
|
BIN
web/frontend/public/favicon.png
Normal file
BIN
web/frontend/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
54
web/frontend/public/global.css
Normal file
54
web/frontend/public/global.css
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
html, body {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-content {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
margin-top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.1rem 1.0rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-list-item {
|
||||||
|
margin: 0rem 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
BIN
web/frontend/public/img/logo.png
Normal file
BIN
web/frontend/public/img/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
1
web/frontend/public/uPlot.min.css
vendored
Symbolic link
1
web/frontend/public/uPlot.min.css
vendored
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../node_modules/uplot/dist/uPlot.min.css
|
70
web/frontend/rollup.config.js
Normal file
70
web/frontend/rollup.config.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import svelte from 'rollup-plugin-svelte';
|
||||||
|
import replace from "@rollup/plugin-replace";
|
||||||
|
import commonjs from '@rollup/plugin-commonjs';
|
||||||
|
import resolve from '@rollup/plugin-node-resolve';
|
||||||
|
import { terser } from 'rollup-plugin-terser';
|
||||||
|
import css from 'rollup-plugin-css-only';
|
||||||
|
|
||||||
|
const production = !process.env.ROLLUP_WATCH;
|
||||||
|
|
||||||
|
const plugins = [
|
||||||
|
svelte({
|
||||||
|
compilerOptions: {
|
||||||
|
// enable run-time checks when not in production
|
||||||
|
dev: !production
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// If you have external dependencies installed from
|
||||||
|
// npm, you'll most likely need these plugins. In
|
||||||
|
// some cases you'll need additional configuration -
|
||||||
|
// consult the documentation for details:
|
||||||
|
// https://github.com/rollup/plugins/tree/master/packages/commonjs
|
||||||
|
resolve({
|
||||||
|
browser: true,
|
||||||
|
dedupe: ['svelte']
|
||||||
|
}),
|
||||||
|
commonjs(),
|
||||||
|
|
||||||
|
// If we're building for production (npm run build
|
||||||
|
// instead of npm run dev), minify
|
||||||
|
production && terser(),
|
||||||
|
|
||||||
|
replace({
|
||||||
|
"process.env.NODE_ENV": JSON.stringify("development"),
|
||||||
|
preventAssignment: true
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const entrypoint = (name, path) => ({
|
||||||
|
input: path,
|
||||||
|
output: {
|
||||||
|
sourcemap: false,
|
||||||
|
format: 'iife',
|
||||||
|
name: 'app',
|
||||||
|
file: `public/build/${name}.js`
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
...plugins,
|
||||||
|
|
||||||
|
// we'll extract any component CSS out into
|
||||||
|
// a separate file - better for performance
|
||||||
|
css({ output: `${name}.css` }),
|
||||||
|
],
|
||||||
|
watch: {
|
||||||
|
clearScreen: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
entrypoint('header', 'src/header.entrypoint.js'),
|
||||||
|
entrypoint('jobs', 'src/jobs.entrypoint.js'),
|
||||||
|
entrypoint('user', 'src/user.entrypoint.js'),
|
||||||
|
entrypoint('list', 'src/list.entrypoint.js'),
|
||||||
|
entrypoint('job', 'src/job.entrypoint.js'),
|
||||||
|
entrypoint('systems', 'src/systems.entrypoint.js'),
|
||||||
|
entrypoint('node', 'src/node.entrypoint.js'),
|
||||||
|
entrypoint('analysis', 'src/analysis.entrypoint.js'),
|
||||||
|
entrypoint('status', 'src/status.entrypoint.js')
|
||||||
|
];
|
||||||
|
|
265
web/frontend/src/Analysis.root.svelte
Normal file
265
web/frontend/src/Analysis.root.svelte
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
<script>
|
||||||
|
import { init } from './utils.js'
|
||||||
|
import { getContext, onMount } from 'svelte'
|
||||||
|
import { operationStore, query } from '@urql/svelte'
|
||||||
|
import { Row, Col, Spinner, Card, Table } from 'sveltestrap'
|
||||||
|
import Filters from './filters/Filters.svelte'
|
||||||
|
import PlotSelection from './PlotSelection.svelte'
|
||||||
|
import Histogram, { binsFromFootprint } from './plots/Histogram.svelte'
|
||||||
|
import ScatterPlot from './plots/Scatter.svelte'
|
||||||
|
import PlotTable from './PlotTable.svelte'
|
||||||
|
import Roofline from './plots/Roofline.svelte'
|
||||||
|
|
||||||
|
const { query: initq } = init()
|
||||||
|
|
||||||
|
export let filterPresets
|
||||||
|
|
||||||
|
// By default, look at the jobs of the last 6 hours:
|
||||||
|
if (filterPresets?.startTime == null) {
|
||||||
|
if (filterPresets == null)
|
||||||
|
filterPresets = {}
|
||||||
|
|
||||||
|
let now = new Date(Date.now())
|
||||||
|
let hourAgo = new Date(now)
|
||||||
|
hourAgo.setHours(hourAgo.getHours() - 6)
|
||||||
|
filterPresets.startTime = { from: hourAgo.toISOString(), to: now.toISOString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
let cluster
|
||||||
|
let filters
|
||||||
|
let rooflineMaxY
|
||||||
|
let colWidth
|
||||||
|
let numBins = 50
|
||||||
|
const ccconfig = getContext('cc-config'),
|
||||||
|
metricConfig = getContext('metrics')
|
||||||
|
|
||||||
|
let metricsInHistograms = ccconfig.analysis_view_histogramMetrics,
|
||||||
|
metricsInScatterplots = ccconfig.analysis_view_scatterPlotMetrics
|
||||||
|
|
||||||
|
$: metrics = [...new Set([...metricsInHistograms, ...metricsInScatterplots.flat()])]
|
||||||
|
|
||||||
|
getContext('on-init')(({ data }) => {
|
||||||
|
if (data != null) {
|
||||||
|
cluster = data.clusters.find(c => c.name == filterPresets.cluster)
|
||||||
|
console.assert(cluster != null, `This cluster could not be found: ${filterPresets.cluster}`)
|
||||||
|
|
||||||
|
rooflineMaxY = cluster.subClusters.reduce((max, part) => Math.max(max, part.flopRateSimd), 0)
|
||||||
|
$rooflineQuery.variables.maxY = rooflineMaxY
|
||||||
|
$rooflineQuery.context.pause = false
|
||||||
|
$rooflineQuery.reexecute()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statsQuery = operationStore(`
|
||||||
|
query($filter: [JobFilter!]!) {
|
||||||
|
stats: jobsStatistics(filter: $filter) {
|
||||||
|
totalJobs
|
||||||
|
shortJobs
|
||||||
|
totalWalltime
|
||||||
|
totalCoreHours
|
||||||
|
histDuration { count, value }
|
||||||
|
histNumNodes { count, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
topUsers: jobsCount(filter: $filter, groupBy: USER, weight: NODE_HOURS, limit: 5) { name, count }
|
||||||
|
}
|
||||||
|
`, { filter: [] }, { pause: true })
|
||||||
|
|
||||||
|
const footprintsQuery = operationStore(`
|
||||||
|
query($filter: [JobFilter!]!, $metrics: [String!]!) {
|
||||||
|
footprints: jobsFootprints(filter: $filter, metrics: $metrics) {
|
||||||
|
nodehours,
|
||||||
|
metrics { metric, data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, { filter: [], metrics }, { pause: true })
|
||||||
|
$: $footprintsQuery.variables = { ...$footprintsQuery.variables, metrics }
|
||||||
|
|
||||||
|
const rooflineQuery = operationStore(`
|
||||||
|
query($filter: [JobFilter!]!, $rows: Int!, $cols: Int!,
|
||||||
|
$minX: Float!, $minY: Float!, $maxX: Float!, $maxY: Float!) {
|
||||||
|
rooflineHeatmap(filter: $filter, rows: $rows, cols: $cols,
|
||||||
|
minX: $minX, minY: $minY, maxX: $maxX, maxY: $maxY)
|
||||||
|
}
|
||||||
|
`, {
|
||||||
|
filter: [],
|
||||||
|
rows: 50, cols: 50,
|
||||||
|
minX: 0.01, minY: 1., maxX: 1000., maxY: -1
|
||||||
|
}, { pause: true });
|
||||||
|
|
||||||
|
query(statsQuery)
|
||||||
|
query(footprintsQuery)
|
||||||
|
query(rooflineQuery)
|
||||||
|
onMount(() => filters.update())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
{#if $initq.fetching || $statsQuery.fetching || $footprintsQuery.fetching}
|
||||||
|
<Col xs="auto">
|
||||||
|
<Spinner />
|
||||||
|
</Col>
|
||||||
|
{/if}
|
||||||
|
<Col xs="auto">
|
||||||
|
{#if $initq.error}
|
||||||
|
<Card body color="danger">{$initq.error.message}</Card>
|
||||||
|
{:else if cluster}
|
||||||
|
<PlotSelection
|
||||||
|
availableMetrics={cluster.metricConfig.map(mc => mc.name)}
|
||||||
|
bind:metricsInHistograms={metricsInHistograms}
|
||||||
|
bind:metricsInScatterplots={metricsInScatterplots} />
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
<Col xs="auto">
|
||||||
|
<Filters
|
||||||
|
bind:this={filters}
|
||||||
|
filterPresets={filterPresets}
|
||||||
|
disableClusterSelection={true}
|
||||||
|
startTimeQuickSelect={true}
|
||||||
|
on:update={({ detail }) => {
|
||||||
|
$statsQuery.context.pause = false
|
||||||
|
$statsQuery.variables = { filter: detail.filters }
|
||||||
|
$footprintsQuery.context.pause = false
|
||||||
|
$footprintsQuery.variables = { metrics, filter: detail.filters }
|
||||||
|
$rooflineQuery.variables = { ...$rooflineQuery.variables, filter: detail.filters }
|
||||||
|
}} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
{#if $statsQuery.error}
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<Card body color="danger">{$statsQuery.error.message}</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{:else if $statsQuery.data}
|
||||||
|
<Row>
|
||||||
|
<div class="col-3" bind:clientWidth={colWidth}>
|
||||||
|
<div style="height: 40%">
|
||||||
|
<Table>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Total Jobs</th>
|
||||||
|
<td>{$statsQuery.data.stats[0].totalJobs}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Short Jobs (< 2m)</th>
|
||||||
|
<td>{$statsQuery.data.stats[0].shortJobs}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Total Walltime</th>
|
||||||
|
<td>{$statsQuery.data.stats[0].totalWalltime}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Total Core Hours</th>
|
||||||
|
<td>{$statsQuery.data.stats[0].totalCoreHours}</td>
|
||||||
|
</tr>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div style="height: 60%;">
|
||||||
|
{#key $statsQuery.data.topUsers}
|
||||||
|
<h4>Top Users (by node hours)</h4>
|
||||||
|
<Histogram
|
||||||
|
width={colWidth - 25} height={300 * 0.5}
|
||||||
|
data={$statsQuery.data.topUsers.sort((a, b) => b.count - a.count).map(({ count }, idx) => ({ count, value: idx }))}
|
||||||
|
label={(x) => x < $statsQuery.data.topUsers.length ? $statsQuery.data.topUsers[Math.floor(x)].name : '0'} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
{#key $statsQuery.data.stats[0].histDuration}
|
||||||
|
<h4>Walltime Distribution</h4>
|
||||||
|
<Histogram
|
||||||
|
width={colWidth - 25} height={300}
|
||||||
|
data={$statsQuery.data.stats[0].histDuration} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
{#key $statsQuery.data.stats[0].histNumNodes}
|
||||||
|
<h4>Number of Nodes Distribution</h4>
|
||||||
|
<Histogram
|
||||||
|
width={colWidth - 25} height={300}
|
||||||
|
data={$statsQuery.data.stats[0].histNumNodes} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
{#if $rooflineQuery.fetching}
|
||||||
|
<Spinner />
|
||||||
|
{:else if $rooflineQuery.error}
|
||||||
|
<Card body color="danger">{$rooflineQuery.error.message}</Card>
|
||||||
|
{:else if $rooflineQuery.data && cluster}
|
||||||
|
{#key $rooflineQuery.data}
|
||||||
|
<Roofline
|
||||||
|
width={colWidth - 25} height={300}
|
||||||
|
tiles={$rooflineQuery.data.rooflineHeatmap}
|
||||||
|
cluster={cluster.subClusters.length == 1 ? cluster.subClusters[0] : null}
|
||||||
|
maxY={rooflineMaxY} />
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
{#if $footprintsQuery.error}
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<Card body color="danger">{$footprintsQuery.error.message}</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{:else if $footprintsQuery.data && $initq.data}
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<Card body>
|
||||||
|
These histograms show the distribution of the averages of all jobs matching the filters. Each job/average is weighted by its node hours.
|
||||||
|
</Card>
|
||||||
|
<br/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<PlotTable
|
||||||
|
let:item
|
||||||
|
let:width
|
||||||
|
items={metricsInHistograms.map(metric => ({ metric, ...binsFromFootprint(
|
||||||
|
$footprintsQuery.data.footprints.nodehours,
|
||||||
|
$footprintsQuery.data.footprints.metrics.find(f => f.metric == metric).data, numBins) }))}
|
||||||
|
itemsPerRow={ccconfig.plot_view_plotsPerRow}>
|
||||||
|
<h4>{item.metric} [{metricConfig(cluster.name, item.metric)?.unit}]</h4>
|
||||||
|
|
||||||
|
<Histogram
|
||||||
|
width={width} height={250}
|
||||||
|
min={item.min} max={item.max}
|
||||||
|
data={item.bins} label={item.label} />
|
||||||
|
</PlotTable>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<br/>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<Card body>
|
||||||
|
Each circle represents one job. The size of a circle is proportional to its node hours. Darker circles mean multiple jobs have the same averages for the respective metrics.
|
||||||
|
</Card>
|
||||||
|
<br/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<PlotTable
|
||||||
|
let:item
|
||||||
|
let:width
|
||||||
|
items={metricsInScatterplots.map(([m1, m2]) => ({
|
||||||
|
m1, f1: $footprintsQuery.data.footprints.metrics.find(f => f.metric == m1).data,
|
||||||
|
m2, f2: $footprintsQuery.data.footprints.metrics.find(f => f.metric == m2).data }))}
|
||||||
|
itemsPerRow={ccconfig.plot_view_plotsPerRow}>
|
||||||
|
|
||||||
|
<ScatterPlot
|
||||||
|
width={width} height={250} color={"rgba(0, 102, 204, 0.33)"}
|
||||||
|
xLabel={`${item.m1} [${metricConfig(cluster.name, item.m1)?.unit}]`}
|
||||||
|
yLabel={`${item.m2} [${metricConfig(cluster.name, item.m2)?.unit}]`}
|
||||||
|
X={item.f1} Y={item.f2} S={$footprintsQuery.data.footprints.nodehours} />
|
||||||
|
</PlotTable>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
73
web/frontend/src/Header.svelte
Normal file
73
web/frontend/src/Header.svelte
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<script>
|
||||||
|
import { Icon, Button, InputGroup, Input, Collapse,
|
||||||
|
Navbar, NavbarBrand, Nav, NavItem, NavLink, NavbarToggler,
|
||||||
|
Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'sveltestrap'
|
||||||
|
|
||||||
|
export let username // empty string if auth. is disabled, otherwise the username as string
|
||||||
|
export let isAdmin // boolean
|
||||||
|
export let clusters // array of names
|
||||||
|
|
||||||
|
let isOpen = false
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
isAdmin
|
||||||
|
? { title: 'Jobs', adminOnly: false, href: '/monitoring/jobs/', icon: 'card-list' }
|
||||||
|
: { title: 'My Jobs', adminOnly: false, href: `/monitoring/user/${username}`, icon: 'bar-chart-line-fill' },
|
||||||
|
{ title: 'Users', adminOnly: true, href: '/monitoring/users/', icon: 'people-fill' },
|
||||||
|
{ title: 'Projects', adminOnly: true, href: '/monitoring/projects/', icon: 'folder' },
|
||||||
|
{ title: 'Tags', adminOnly: false, href: '/monitoring/tags/', icon: 'tags' }
|
||||||
|
]
|
||||||
|
const viewsPerCluster = [
|
||||||
|
{ title: 'Analysis', adminOnly: true, href: '/monitoring/analysis/', icon: 'graph-up' },
|
||||||
|
{ title: 'Systems', adminOnly: true, href: '/monitoring/systems/', icon: 'cpu' },
|
||||||
|
{ title: 'Status', adminOnly: true, href: '/monitoring/status/', icon: 'cpu' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Navbar color="light" light expand="lg" fixed="top">
|
||||||
|
<NavbarBrand href="/">
|
||||||
|
<img alt="ClusterCockpit Logo" src="/img/logo.png" height="25rem">
|
||||||
|
</NavbarBrand>
|
||||||
|
<NavbarToggler on:click={() => (isOpen = !isOpen)} />
|
||||||
|
<Collapse {isOpen} navbar expand="lg" on:update={({ detail }) => (isOpen = detail.isOpen)}>
|
||||||
|
<Nav pills>
|
||||||
|
{#each views.filter(item => isAdmin || !item.adminOnly) as item}
|
||||||
|
<NavLink href={item.href} active={window.location.pathname == item.href}><Icon name={item.icon}/> {item.title}</NavLink>
|
||||||
|
{/each}
|
||||||
|
{#each viewsPerCluster.filter(item => !item.adminOnly || isAdmin) as item}
|
||||||
|
<NavItem>
|
||||||
|
<Dropdown nav inNavbar>
|
||||||
|
<DropdownToggle nav caret>
|
||||||
|
<Icon name={item.icon}/> {item.title}
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu>
|
||||||
|
{#each clusters as cluster}
|
||||||
|
<DropdownItem href={item.href + cluster} active={window.location.pathname == item.href + cluster}>
|
||||||
|
{cluster}
|
||||||
|
</DropdownItem>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</NavItem>
|
||||||
|
{/each}
|
||||||
|
</Nav>
|
||||||
|
</Collapse>
|
||||||
|
<div class="d-flex">
|
||||||
|
<form method="GET" action="/search">
|
||||||
|
<InputGroup>
|
||||||
|
<Input type="text" placeholder={isAdmin ? "Search jobId / username" : "Search jobId"} name="searchId"/>
|
||||||
|
<Button outline type="submit"><Icon name="search"/></Button>
|
||||||
|
</InputGroup>
|
||||||
|
</form>
|
||||||
|
{#if username}
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<Button outline color="success" type="submit" style="margin-left: 10px;">
|
||||||
|
<Icon name="box-arrow-right"/> Logout {username}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
<Button outline on:click={() => window.location.href = '/config'} style="margin-left: 10px;">
|
||||||
|
<Icon name="gear"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Navbar>
|
224
web/frontend/src/Job.root.svelte
Normal file
224
web/frontend/src/Job.root.svelte
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
<script>
|
||||||
|
import { init, groupByScope, fetchMetricsStore } from './utils.js'
|
||||||
|
import { Row, Col, Card, Spinner, TabContent, TabPane,
|
||||||
|
CardBody, CardHeader, CardTitle, Button, Icon } from 'sveltestrap'
|
||||||
|
import PlotTable from './PlotTable.svelte'
|
||||||
|
import Metric from './Metric.svelte'
|
||||||
|
import PolarPlot from './plots/Polar.svelte'
|
||||||
|
import Roofline from './plots/Roofline.svelte'
|
||||||
|
import JobInfo from './joblist/JobInfo.svelte'
|
||||||
|
import TagManagement from './TagManagement.svelte'
|
||||||
|
import MetricSelection from './MetricSelection.svelte'
|
||||||
|
import Zoom from './Zoom.svelte'
|
||||||
|
import StatsTable from './StatsTable.svelte'
|
||||||
|
import { getContext } from 'svelte'
|
||||||
|
|
||||||
|
export let dbid
|
||||||
|
|
||||||
|
const { query: initq } = init(`
|
||||||
|
job(id: "${dbid}") {
|
||||||
|
id, jobId, user, project, cluster, startTime,
|
||||||
|
duration, numNodes, numHWThreads, numAcc,
|
||||||
|
SMT, exclusive, partition, subCluster, arrayJobId,
|
||||||
|
monitoringStatus, state, walltime,
|
||||||
|
tags { id, type, name },
|
||||||
|
resources { hostname, hwthreads, accelerators },
|
||||||
|
metaData
|
||||||
|
userData { name, email }
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
const ccconfig = getContext('cc-config'),
|
||||||
|
clusters = getContext('clusters')
|
||||||
|
|
||||||
|
let isMetricsSelectionOpen = false, selectedMetrics = []
|
||||||
|
const [jobMetrics, startFetching] = fetchMetricsStore()
|
||||||
|
getContext('on-init')(() => {
|
||||||
|
let job = $initq.data.job
|
||||||
|
if (!job)
|
||||||
|
return
|
||||||
|
|
||||||
|
startFetching(job, null, job.numNodes > 2 ? ["node"] : ["node", "core"])
|
||||||
|
|
||||||
|
// TODO: Do not even fetch metrics that are not one of the following: flops_any, mem_bw, job_view_selectedMetrics, job_view_nodestats_selectedMetrics
|
||||||
|
selectedMetrics = ccconfig[`job_view_selectedMetrics:${job.cluster}`]
|
||||||
|
|| clusters.find(c => c.name == job.cluster).metricConfig.map(mc => mc.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
let plots = {}, jobTags, fullWidth, statsTable
|
||||||
|
$: polarPlotSize = Math.min(fullWidth / 3 - 10, 300)
|
||||||
|
$: document.title = $initq.fetching ? 'Loading...' : ($initq.error ? 'Error' : `Job ${$initq.data.job.jobId} - ClusterCockpit`)
|
||||||
|
|
||||||
|
let missingMetrics = [], missingHosts = [], somethingMissing = false
|
||||||
|
$: if ($initq.data && $jobMetrics.data) {
|
||||||
|
let job = $initq.data.job,
|
||||||
|
metrics = $jobMetrics.data.jobMetrics,
|
||||||
|
metricNames = clusters.find(c => c.name == job.cluster).metricConfig.map(mc => mc.name)
|
||||||
|
|
||||||
|
missingMetrics = metricNames.filter(metric => !metrics.some(jm => jm.name == metric))
|
||||||
|
missingHosts = job.resources.map(({ hostname }) => ({
|
||||||
|
hostname: hostname,
|
||||||
|
metrics: metricNames.filter(metric => !metrics.some(jm => jm.metric.scope == 'node' && jm.metric.series.some(series => series.hostname == hostname)))
|
||||||
|
})).filter(({ metrics }) => metrics.length > 0)
|
||||||
|
somethingMissing = missingMetrics.length > 0 || missingHosts.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderAndMap = (grouped, selectedMetrics) => selectedMetrics.map(metric => ({ metric: metric, data: grouped.find((group) => group[0].name == metric) }))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="row" bind:clientWidth={fullWidth}></div>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
{#if $initq.error}
|
||||||
|
<Card body color="danger">{$initq.error.message}</Card>
|
||||||
|
{:else if $initq.data}
|
||||||
|
<JobInfo job={$initq.data.job} jobTags={jobTags}/>
|
||||||
|
{:else}
|
||||||
|
<Spinner secondary/>
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
{#if $jobMetrics.data && $initq.data}
|
||||||
|
<Col>
|
||||||
|
<PolarPlot
|
||||||
|
width={polarPlotSize} height={polarPlotSize}
|
||||||
|
metrics={ccconfig.job_view_polarPlotMetrics}
|
||||||
|
cluster={$initq.data.job.cluster}
|
||||||
|
jobMetrics={$jobMetrics.data.jobMetrics} />
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Roofline
|
||||||
|
width={fullWidth / 3 - 10} height={polarPlotSize}
|
||||||
|
cluster={clusters
|
||||||
|
.find(c => c.name == $initq.data.job.cluster).subClusters
|
||||||
|
.find(sc => sc.name == $initq.data.job.subCluster)}
|
||||||
|
flopsAny={$jobMetrics.data.jobMetrics.find(m => m.name == 'flops_any' && m.metric.scope == 'node').metric}
|
||||||
|
memBw={$jobMetrics.data.jobMetrics.find(m => m.name == 'mem_bw' && m.metric.scope == 'node').metric} />
|
||||||
|
</Col>
|
||||||
|
{:else}
|
||||||
|
<Col></Col>
|
||||||
|
<Col></Col>
|
||||||
|
{/if}
|
||||||
|
</Row>
|
||||||
|
<br/>
|
||||||
|
<Row>
|
||||||
|
<Col xs="auto">
|
||||||
|
{#if $initq.data}
|
||||||
|
<TagManagement job={$initq.data.job} bind:jobTags={jobTags}/>
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
<Col xs="auto">
|
||||||
|
{#if $initq.data}
|
||||||
|
<Button outline
|
||||||
|
on:click={() => (isMetricsSelectionOpen = true)}>
|
||||||
|
<Icon name="graph-up"/> Metrics
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
<Col xs="auto">
|
||||||
|
<Zoom timeseriesPlots={plots} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<br/>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
{#if $jobMetrics.error}
|
||||||
|
{#if $initq.data.job.monitoringStatus == 0 || $initq.data.job.monitoringStatus == 2}
|
||||||
|
<Card body color="warning">Not monitored or archiving failed</Card>
|
||||||
|
<br/>
|
||||||
|
{/if}
|
||||||
|
<Card body color="danger">{$jobMetrics.error.message}</Card>
|
||||||
|
{:else if $jobMetrics.fetching}
|
||||||
|
<Spinner secondary/>
|
||||||
|
{:else if $jobMetrics.data && $initq.data}
|
||||||
|
<PlotTable
|
||||||
|
let:item
|
||||||
|
let:width
|
||||||
|
items={orderAndMap(groupByScope($jobMetrics.data.jobMetrics), selectedMetrics)}
|
||||||
|
itemsPerRow={ccconfig.plot_view_plotsPerRow}>
|
||||||
|
{#if item.data}
|
||||||
|
<Metric
|
||||||
|
bind:this={plots[item.metric]}
|
||||||
|
on:more-loaded={({ detail }) => statsTable.moreLoaded(detail)}
|
||||||
|
job={$initq.data.job}
|
||||||
|
metric={item.metric}
|
||||||
|
scopes={item.data.map(x => x.metric)}
|
||||||
|
width={width}/>
|
||||||
|
{:else}
|
||||||
|
<Card body color="warning">No data for <code>{item.metric}</code></Card>
|
||||||
|
{/if}
|
||||||
|
</PlotTable>
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<br/>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
{#if $initq.data}
|
||||||
|
<TabContent>
|
||||||
|
{#if somethingMissing}
|
||||||
|
<TabPane tabId="resources" tab="Resources" active={somethingMissing}>
|
||||||
|
<div style="margin: 10px;"><Card color="warning">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Missing Metrics/Reseources</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{#if missingMetrics.length > 0}
|
||||||
|
<p>No data at all is available for the metrics: {missingMetrics.join(', ')}</p>
|
||||||
|
{/if}
|
||||||
|
{#if missingHosts.length > 0}
|
||||||
|
<p>Some metrics are missing for the following hosts:</p>
|
||||||
|
<ul>
|
||||||
|
{#each missingHosts as missing}
|
||||||
|
<li>{missing.hostname}: {missing.metrics.join(', ')}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</CardBody>
|
||||||
|
</Card></div>
|
||||||
|
</TabPane>
|
||||||
|
{/if}
|
||||||
|
<TabPane tabId="stats" tab="Statistics Table" active={!somethingMissing}>
|
||||||
|
{#if $jobMetrics.data}
|
||||||
|
<StatsTable bind:this={statsTable} job={$initq.data.job} jobMetrics={$jobMetrics.data.jobMetrics} />
|
||||||
|
{/if}
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tabId="job-script" tab="Job Script">
|
||||||
|
<div class="pre-wrapper">
|
||||||
|
{#if $initq.data.job.metaData?.jobScript}
|
||||||
|
<pre><code>{$initq.data.job.metaData?.jobScript}</code></pre>
|
||||||
|
{:else}
|
||||||
|
<Card body color="warning">No job script available</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tabId="slurm-info" tab="Slurm Info">
|
||||||
|
<div class="pre-wrapper">
|
||||||
|
{#if $initq.data.job.metaData?.slurmInfo}
|
||||||
|
<pre><code>{$initq.data.job.metaData?.slurmInfo}</code></pre>
|
||||||
|
{:else}
|
||||||
|
<Card body color="warning">No additional slurm information available</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
</TabContent>
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{#if $initq.data}
|
||||||
|
<MetricSelection
|
||||||
|
cluster={$initq.data.job.cluster}
|
||||||
|
configName="job_view_selectedMetrics"
|
||||||
|
bind:metrics={selectedMetrics}
|
||||||
|
bind:isOpen={isMetricsSelectionOpen} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pre-wrapper {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 10px;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
88
web/frontend/src/Jobs.root.svelte
Normal file
88
web/frontend/src/Jobs.root.svelte
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, getContext } from 'svelte'
|
||||||
|
import { init } from './utils.js'
|
||||||
|
import { Row, Col, Button, Icon, Card, Spinner } from 'sveltestrap'
|
||||||
|
import Filters from './filters/Filters.svelte'
|
||||||
|
import JobList from './joblist/JobList.svelte'
|
||||||
|
import Refresher from './joblist/Refresher.svelte'
|
||||||
|
import Sorting from './joblist/SortSelection.svelte'
|
||||||
|
import MetricSelection from './MetricSelection.svelte'
|
||||||
|
import UserOrProject from './filters/UserOrProject.svelte'
|
||||||
|
|
||||||
|
const { query: initq } = init()
|
||||||
|
|
||||||
|
const ccconfig = getContext('cc-config')
|
||||||
|
|
||||||
|
export let filterPresets = {}
|
||||||
|
|
||||||
|
let filters, jobList, matchedJobs = null
|
||||||
|
let sorting = { field: 'startTime', order: 'DESC' }, isSortingOpen = false, isMetricsSelectionOpen = false
|
||||||
|
let metrics = filterPresets.cluster
|
||||||
|
? ccconfig[`plot_list_selectedMetrics:${filterPresets.cluster}`] || ccconfig.plot_list_selectedMetrics
|
||||||
|
: ccconfig.plot_list_selectedMetrics
|
||||||
|
|
||||||
|
// The filterPresets are handled by the Filters component,
|
||||||
|
// so we need to wait for it to be ready before we can start a query.
|
||||||
|
// This is also why JobList component starts out with a paused query.
|
||||||
|
onMount(() => filters.update())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
{#if $initq.fetching}
|
||||||
|
<Col xs="auto">
|
||||||
|
<Spinner/>
|
||||||
|
</Col>
|
||||||
|
{:else if $initq.error}
|
||||||
|
<Col xs="auto">
|
||||||
|
<Card body color="danger">{$initq.error.message}</Card>
|
||||||
|
</Col>
|
||||||
|
{/if}
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Col xs="auto">
|
||||||
|
<Button
|
||||||
|
outline color="primary"
|
||||||
|
on:click={() => (isSortingOpen = true)}>
|
||||||
|
<Icon name="sort-up"/> Sorting
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
outline color="primary"
|
||||||
|
on:click={() => (isMetricsSelectionOpen = true)}>
|
||||||
|
<Icon name="graph-up"/> Metrics
|
||||||
|
</Button>
|
||||||
|
<Button disabled outline>{matchedJobs == null ? 'Loading...' : `${matchedJobs} jobs`}</Button>
|
||||||
|
</Col>
|
||||||
|
<Col xs="auto">
|
||||||
|
<Filters
|
||||||
|
filterPresets={filterPresets}
|
||||||
|
bind:this={filters}
|
||||||
|
on:update={({ detail }) => jobList.update(detail.filters)} />
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs="3" style="margin-left: auto;">
|
||||||
|
<UserOrProject on:update={({ detail }) => filters.update(detail)}/>
|
||||||
|
</Col>
|
||||||
|
<Col xs="2">
|
||||||
|
<Refresher on:reload={() => jobList.update()} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<br/>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<JobList
|
||||||
|
bind:metrics={metrics}
|
||||||
|
bind:sorting={sorting}
|
||||||
|
bind:matchedJobs={matchedJobs}
|
||||||
|
bind:this={jobList} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Sorting
|
||||||
|
bind:sorting={sorting}
|
||||||
|
bind:isOpen={isSortingOpen} />
|
||||||
|
|
||||||
|
<MetricSelection
|
||||||
|
cluster={filterPresets.cluster}
|
||||||
|
configName="plot_list_selectedMetrics"
|
||||||
|
bind:metrics={metrics}
|
||||||
|
bind:isOpen={isMetricsSelectionOpen} />
|
151
web/frontend/src/List.root.svelte
Normal file
151
web/frontend/src/List.root.svelte
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
<!--
|
||||||
|
@component List of users or projects
|
||||||
|
-->
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { init } from './utils.js'
|
||||||
|
import { Row, Col, Button, Icon, Table, Card, Spinner,
|
||||||
|
InputGroup, Input } from 'sveltestrap'
|
||||||
|
import Filters from './filters/Filters.svelte'
|
||||||
|
import { operationStore, query } from '@urql/svelte';
|
||||||
|
import { scramble, scrambleNames } from './joblist/JobInfo.svelte'
|
||||||
|
|
||||||
|
const { } = init()
|
||||||
|
|
||||||
|
export let type
|
||||||
|
export let filterPresets
|
||||||
|
|
||||||
|
console.assert(type == 'USER' || type == 'PROJECT', 'Invalid list type provided!')
|
||||||
|
|
||||||
|
const stats = operationStore(`query($filter: [JobFilter!]!) {
|
||||||
|
rows: jobsStatistics(filter: $filter, groupBy: ${type}) {
|
||||||
|
id
|
||||||
|
totalJobs
|
||||||
|
totalWalltime
|
||||||
|
totalCoreHours
|
||||||
|
}
|
||||||
|
}`, {
|
||||||
|
filter: []
|
||||||
|
}, {
|
||||||
|
pause: true
|
||||||
|
})
|
||||||
|
|
||||||
|
query(stats)
|
||||||
|
|
||||||
|
let filters
|
||||||
|
let nameFilter = ''
|
||||||
|
let sorting = { field: 'totalJobs', direction: 'down' }
|
||||||
|
|
||||||
|
function changeSorting(event, field) {
|
||||||
|
let target = event.target
|
||||||
|
while (target.tagName != 'BUTTON')
|
||||||
|
target = target.parentElement
|
||||||
|
|
||||||
|
let direction = target.children[0].className.includes('up') ? 'down' : 'up'
|
||||||
|
target.children[0].className = `bi-sort-numeric-${direction}`
|
||||||
|
sorting = { field, direction }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sort(stats, sorting, nameFilter) {
|
||||||
|
const cmp = sorting.field == 'id'
|
||||||
|
? (sorting.direction == 'up'
|
||||||
|
? (a, b) => a.id < b.id
|
||||||
|
: (a, b) => a.id > b.id)
|
||||||
|
: (sorting.direction == 'up'
|
||||||
|
? (a, b) => a[sorting.field] - b[sorting.field]
|
||||||
|
: (a, b) => b[sorting.field] - a[sorting.field])
|
||||||
|
|
||||||
|
return stats.filter(u => u.id.includes(nameFilter)).sort(cmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => filters.update())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col xs="auto">
|
||||||
|
<InputGroup>
|
||||||
|
<Button disabled outline>
|
||||||
|
Search {type.toLowerCase()}s
|
||||||
|
</Button>
|
||||||
|
<Input bind:value={nameFilter} placeholder="Filter by {({ USER: 'username', PROJECT: 'project' })[type]}" />
|
||||||
|
</InputGroup>
|
||||||
|
</Col>
|
||||||
|
<Col xs="auto">
|
||||||
|
<Filters
|
||||||
|
bind:this={filters}
|
||||||
|
filterPresets={filterPresets}
|
||||||
|
startTimeQuickSelect={true}
|
||||||
|
menuText="Only {type.toLowerCase()}s with jobs that match the filters will show up"
|
||||||
|
on:update={({ detail }) => {
|
||||||
|
$stats.variables = { filter: detail.filters }
|
||||||
|
$stats.context.pause = false
|
||||||
|
$stats.reexecute()
|
||||||
|
}} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">
|
||||||
|
{({ USER: 'Username', PROJECT: 'Project Name' })[type]}
|
||||||
|
<Button color="{sorting.field == 'id' ? 'primary' : 'light'}"
|
||||||
|
size="sm" on:click={e => changeSorting(e, 'id')}>
|
||||||
|
<Icon name="sort-numeric-down" />
|
||||||
|
</Button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
Total Jobs
|
||||||
|
<Button color="{sorting.field == 'totalJobs' ? 'primary' : 'light'}"
|
||||||
|
size="sm" on:click={e => changeSorting(e, 'totalJobs')}>
|
||||||
|
<Icon name="sort-numeric-down" />
|
||||||
|
</Button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
Total Walltime
|
||||||
|
<Button color="{sorting.field == 'totalWalltime' ? 'primary' : 'light'}"
|
||||||
|
size="sm" on:click={e => changeSorting(e, 'totalWalltime')}>
|
||||||
|
<Icon name="sort-numeric-down" />
|
||||||
|
</Button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
Total Core Hours
|
||||||
|
<Button color="{sorting.field == 'totalCoreHours' ? 'primary' : 'light'}"
|
||||||
|
size="sm" on:click={e => changeSorting(e, 'totalCoreHours')}>
|
||||||
|
<Icon name="sort-numeric-down" />
|
||||||
|
</Button>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if $stats.fetching}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="text-align: center;"><Spinner secondary/></td>
|
||||||
|
</tr>
|
||||||
|
{:else if $stats.error}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4"><Card body color="danger" class="mb-3">{$stats.error.message}</Card></td>
|
||||||
|
</tr>
|
||||||
|
{:else if $stats.data}
|
||||||
|
{#each sort($stats.data.rows, sorting, nameFilter) as row (row.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{#if type == 'USER'}
|
||||||
|
<a href="/monitoring/user/{row.id}">{scrambleNames ? scramble(row.id) : row.id}</a>
|
||||||
|
{:else if type == 'PROJECT'}
|
||||||
|
<a href="/monitoring/jobs/?project={row.id}">{row.id}</a>
|
||||||
|
{:else}
|
||||||
|
{row.id}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>{row.totalJobs}</td>
|
||||||
|
<td>{row.totalWalltime}</td>
|
||||||
|
<td>{row.totalCoreHours}</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4"><i>No {type.toLowerCase()}s/jobs found</i></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
88
web/frontend/src/Metric.svelte
Normal file
88
web/frontend/src/Metric.svelte
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<script>
|
||||||
|
import { getContext, createEventDispatcher } from 'svelte'
|
||||||
|
import Timeseries from './plots/MetricPlot.svelte'
|
||||||
|
import { InputGroup, InputGroupText, Spinner, Card } from 'sveltestrap'
|
||||||
|
import { fetchMetrics, minScope } from './utils'
|
||||||
|
|
||||||
|
export let job
|
||||||
|
export let metric
|
||||||
|
export let scopes
|
||||||
|
export let width
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
const cluster = getContext('clusters').find(cluster => cluster.name == job.cluster)
|
||||||
|
const subCluster = cluster.subClusters.find(subCluster => subCluster.name == job.subCluster)
|
||||||
|
const metricConfig = cluster.metricConfig.find(metricConfig => metricConfig.name == metric)
|
||||||
|
|
||||||
|
let selectedScope = minScope(scopes.map(s => s.scope)), selectedHost = null, plot, fetching = false, error = null
|
||||||
|
|
||||||
|
$: avaliableScopes = scopes.map(metric => metric.scope)
|
||||||
|
$: data = scopes.find(metric => metric.scope == selectedScope)
|
||||||
|
$: series = data?.series.filter(series => selectedHost == null || series.hostname == selectedHost)
|
||||||
|
|
||||||
|
let from = null, to = null
|
||||||
|
export function setTimeRange(f, t) {
|
||||||
|
from = f, to = t
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (plot != null) plot.setTimeRange(from, to)
|
||||||
|
|
||||||
|
export async function loadMore() {
|
||||||
|
fetching = true
|
||||||
|
let response = await fetchMetrics(job, [metric], ["core"])
|
||||||
|
fetching = false
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
error = response.error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let jm of response.data.jobMetrics) {
|
||||||
|
if (jm.metric.scope != "node") {
|
||||||
|
scopes.push(jm.metric)
|
||||||
|
selectedScope = jm.metric.scope
|
||||||
|
dispatch('more-loaded', jm)
|
||||||
|
if (!avaliableScopes.includes(selectedScope))
|
||||||
|
avaliableScopes = [...avaliableScopes, selectedScope]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (selectedScope == "load-more") loadMore()
|
||||||
|
</script>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupText style="min-width: 150px;">
|
||||||
|
{metric} ({metricConfig?.unit})
|
||||||
|
</InputGroupText>
|
||||||
|
<select class="form-select" bind:value={selectedScope}>
|
||||||
|
{#each avaliableScopes as scope}
|
||||||
|
<option value={scope}>{scope}</option>
|
||||||
|
{/each}
|
||||||
|
{#if avaliableScopes.length == 1 && metricConfig?.scope != "node"}
|
||||||
|
<option value={"load-more"}>Load more...</option>
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
{#if job.resources.length > 1}
|
||||||
|
<select class="form-select" bind:value={selectedHost}>
|
||||||
|
<option value={null}>All Hosts</option>
|
||||||
|
{#each job.resources as { hostname }}
|
||||||
|
<option value={hostname}>{hostname}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</InputGroup>
|
||||||
|
{#key series}
|
||||||
|
{#if fetching == true}
|
||||||
|
<Spinner/>
|
||||||
|
{:else if error != null}
|
||||||
|
<Card body color="danger">{error.message}</Card>
|
||||||
|
{:else if series != null}
|
||||||
|
<Timeseries
|
||||||
|
bind:this={plot}
|
||||||
|
width={width} height={300}
|
||||||
|
cluster={cluster} subCluster={subCluster}
|
||||||
|
timestep={data.timestep}
|
||||||
|
scope={selectedScope} metric={metric}
|
||||||
|
series={series} />
|
||||||
|
{/if}
|
||||||
|
{/key}
|
126
web/frontend/src/MetricSelection.svelte
Normal file
126
web/frontend/src/MetricSelection.svelte
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<!--
|
||||||
|
@component
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
- metrics: [String] (changes from inside, needs to be initialised, list of selected metrics)
|
||||||
|
- isOpen: Boolean (can change from inside and outside)
|
||||||
|
- configName: String (constant)
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Modal, ModalBody, ModalHeader, ModalFooter, Button, ListGroup } from 'sveltestrap'
|
||||||
|
import { getContext } from 'svelte'
|
||||||
|
import { mutation } from '@urql/svelte'
|
||||||
|
|
||||||
|
export let metrics
|
||||||
|
export let isOpen
|
||||||
|
export let configName
|
||||||
|
export let allMetrics = null
|
||||||
|
export let cluster = null
|
||||||
|
|
||||||
|
const clusters = getContext('clusters'),
|
||||||
|
onInit = getContext('on-init')
|
||||||
|
|
||||||
|
let newMetricsOrder = []
|
||||||
|
let unorderedMetrics = [...metrics]
|
||||||
|
|
||||||
|
onInit(() => {
|
||||||
|
if (allMetrics == null) {
|
||||||
|
allMetrics = new Set()
|
||||||
|
for (let c of clusters)
|
||||||
|
if (cluster == null || c.name == cluster)
|
||||||
|
for (let metric of c.metricConfig)
|
||||||
|
allMetrics.add(metric.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMetricsOrder = [...allMetrics].filter(m => !metrics.includes(m))
|
||||||
|
newMetricsOrder.unshift(...metrics)
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateConfiguration = mutation({
|
||||||
|
query: `mutation($name: String!, $value: String!) {
|
||||||
|
updateConfiguration(name: $name, value: $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
|
||||||
|
|
||||||
|
updateConfiguration({
|
||||||
|
name: cluster == null ? configName : `${configName}:${cluster}`,
|
||||||
|
value: JSON.stringify(metrics)
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (res.error)
|
||||||
|
console.error(res.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
li.cc-config-column {
|
||||||
|
display: block;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.cc-config-column.is-active {
|
||||||
|
background-color: #3273dc;
|
||||||
|
color: #fff;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<Modal isOpen={isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||||
|
<ModalHeader>
|
||||||
|
Configure columns
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<ListGroup>
|
||||||
|
{#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;">
|
||||||
|
{cluster == null ? clusters
|
||||||
|
.filter(cluster => cluster.metricConfig.find(m => m.name == metric) != null)
|
||||||
|
.map(cluster => cluster.name).join(', ') : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ListGroup>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="primary" on:click={closeAndApply}>Close & Apply</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
94
web/frontend/src/Node.root.svelte
Normal file
94
web/frontend/src/Node.root.svelte
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<script>
|
||||||
|
import { init } from './utils.js'
|
||||||
|
import { Row, Col, InputGroup, InputGroupText, Icon, Spinner, Card } from 'sveltestrap'
|
||||||
|
import { operationStore, query } from '@urql/svelte'
|
||||||
|
import TimeSelection from './filters/TimeSelection.svelte'
|
||||||
|
import PlotTable from './PlotTable.svelte'
|
||||||
|
import MetricPlot from './plots/MetricPlot.svelte'
|
||||||
|
import { getContext } from 'svelte'
|
||||||
|
|
||||||
|
export let cluster
|
||||||
|
export let hostname
|
||||||
|
export let from = null
|
||||||
|
export let to = null
|
||||||
|
|
||||||
|
const { query: initq } = init()
|
||||||
|
|
||||||
|
if (from == null || to == null) {
|
||||||
|
to = new Date(Date.now())
|
||||||
|
from = new Date(to.getTime())
|
||||||
|
from.setMinutes(from.getMinutes() - 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ccconfig = getContext('cc-config'), clusters = getContext('clusters')
|
||||||
|
|
||||||
|
const nodesQuery = operationStore(`query($cluster: String!, $nodes: [String!], $from: Time!, $to: Time!) {
|
||||||
|
nodeMetrics(cluster: $cluster, nodes: $nodes, from: $from, to: $to) {
|
||||||
|
host, subCluster
|
||||||
|
metrics {
|
||||||
|
name,
|
||||||
|
metric {
|
||||||
|
timestep
|
||||||
|
scope
|
||||||
|
series {
|
||||||
|
statistics { min, avg, max }
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`, {
|
||||||
|
cluster: cluster,
|
||||||
|
nodes: [hostname],
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
$: $nodesQuery.variables = { cluster, nodes: [hostname], from: from.toISOString(), to: to.toISOString() }
|
||||||
|
|
||||||
|
query(nodesQuery)
|
||||||
|
|
||||||
|
$: console.log($nodesQuery?.data?.nodeMetrics[0].metrics)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
{#if $initq.error}
|
||||||
|
<Card body color="danger">{$initq.error.message}</Card>
|
||||||
|
{:else if $initq.fetching}
|
||||||
|
<Spinner/>
|
||||||
|
{:else}
|
||||||
|
<Col>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupText><Icon name="hdd"/></InputGroupText>
|
||||||
|
<InputGroupText>{hostname} ({cluster})</InputGroupText>
|
||||||
|
</InputGroup>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<TimeSelection
|
||||||
|
bind:from={from}
|
||||||
|
bind:to={to} />
|
||||||
|
</Col>
|
||||||
|
{/if}
|
||||||
|
</Row>
|
||||||
|
<br/>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
{#if $nodesQuery.error}
|
||||||
|
<Card body color="danger">{$nodesQuery.error.message}</Card>
|
||||||
|
{:else if $nodesQuery.fetching || $initq.fetching}
|
||||||
|
<Spinner/>
|
||||||
|
{:else}
|
||||||
|
<PlotTable
|
||||||
|
let:item
|
||||||
|
let:width
|
||||||
|
itemsPerRow={ccconfig.plot_view_plotsPerRow}
|
||||||
|
items={$nodesQuery.data.nodeMetrics[0].metrics.sort((a, b) => a.name.localeCompare(b.name))}>
|
||||||
|
<h4 style="text-align: center;">{item.name}</h4>
|
||||||
|
<MetricPlot
|
||||||
|
width={width} height={300} metric={item.name} timestep={item.metric.timestep}
|
||||||
|
cluster={clusters.find(c => c.name == cluster)} subCluster={$nodesQuery.data.nodeMetrics[0].subCluster}
|
||||||
|
series={item.metric.series} />
|
||||||
|
</PlotTable>
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
133
web/frontend/src/PlotSelection.svelte
Normal file
133
web/frontend/src/PlotSelection.svelte
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<script>
|
||||||
|
import { Modal, ModalBody, ModalHeader, ModalFooter, InputGroup,
|
||||||
|
Button, ListGroup, ListGroupItem, Icon } from 'sveltestrap'
|
||||||
|
import { mutation } from '@urql/svelte'
|
||||||
|
|
||||||
|
export let availableMetrics
|
||||||
|
export let metricsInHistograms
|
||||||
|
export let metricsInScatterplots
|
||||||
|
|
||||||
|
const updateConfigurationMutation = mutation({
|
||||||
|
query: `mutation($name: String!, $value: String!) {
|
||||||
|
updateConfiguration(name: $name, value: $value)
|
||||||
|
}`
|
||||||
|
})
|
||||||
|
|
||||||
|
let isHistogramConfigOpen = false, isScatterPlotConfigOpen = false
|
||||||
|
let selectedMetric1 = null, selectedMetric2 = null
|
||||||
|
|
||||||
|
function updateConfiguration(data) {
|
||||||
|
updateConfigurationMutation({
|
||||||
|
name: data.name,
|
||||||
|
value: JSON.stringify(data.value)
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (res.error)
|
||||||
|
console.error(res.error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button outline
|
||||||
|
on:click={() => (isHistogramConfigOpen = true)}>
|
||||||
|
<Icon name=""/>
|
||||||
|
Select Plots for Histograms
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button outline
|
||||||
|
on:click={() => (isScatterPlotConfigOpen = true)}>
|
||||||
|
<Icon name=""/>
|
||||||
|
Select Plots in Scatter Plots
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal isOpen={isHistogramConfigOpen}
|
||||||
|
toggle={() => (isHistogramConfigOpen = !isHistogramConfigOpen)}>
|
||||||
|
<ModalHeader>
|
||||||
|
Select metrics presented in histograms
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<ListGroup>
|
||||||
|
{#each availableMetrics as metric (metric)}
|
||||||
|
<ListGroupItem>
|
||||||
|
<input type="checkbox" bind:group={metricsInHistograms}
|
||||||
|
value={metric}
|
||||||
|
on:change={() => updateConfiguration({
|
||||||
|
name: 'analysis_view_histogramMetrics',
|
||||||
|
value: metricsInHistograms
|
||||||
|
})} />
|
||||||
|
|
||||||
|
{metric}
|
||||||
|
</ListGroupItem>
|
||||||
|
{/each}
|
||||||
|
</ListGroup>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="primary"
|
||||||
|
on:click={() => (isHistogramConfigOpen = false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={isScatterPlotConfigOpen}
|
||||||
|
toggle={() => (isScatterPlotConfigOpen = !isScatterPlotConfigOpen)}>
|
||||||
|
<ModalHeader>
|
||||||
|
Select metric pairs presented in scatter plots
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<ListGroup>
|
||||||
|
{#each metricsInScatterplots as pair}
|
||||||
|
<ListGroupItem>
|
||||||
|
<b>{pair[0]}</b> / <b>{pair[1]}</b>
|
||||||
|
|
||||||
|
<Button style="float: right;" outline color="danger"
|
||||||
|
on:click={() => {
|
||||||
|
metricsInScatterplots = metricsInScatterplots.filter(p => pair != p)
|
||||||
|
updateConfiguration({
|
||||||
|
name: 'analysis_view_scatterPlotMetrics',
|
||||||
|
value: metricsInScatterplots
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<Icon name="x" />
|
||||||
|
</Button>
|
||||||
|
</ListGroupItem>
|
||||||
|
{/each}
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<select bind:value={selectedMetric1} class="form-group form-select">
|
||||||
|
<option value={null}>Choose Metric for X Axis</option>
|
||||||
|
{#each availableMetrics as metric}
|
||||||
|
<option value={metric}>{metric}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select bind:value={selectedMetric2} class="form-group form-select">
|
||||||
|
<option value={null}>Choose Metric for Y Axis</option>
|
||||||
|
{#each availableMetrics as metric}
|
||||||
|
<option value={metric}>{metric}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<Button outline disabled={selectedMetric1 == null || selectedMetric2 == null}
|
||||||
|
on:click={() => {
|
||||||
|
metricsInScatterplots = [...metricsInScatterplots, [selectedMetric1, selectedMetric2]]
|
||||||
|
selectedMetric1 = null
|
||||||
|
selectedMetric2 = null
|
||||||
|
updateConfiguration({
|
||||||
|
name: 'analysis_view_scatterPlotMetrics',
|
||||||
|
value: metricsInScatterplots
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
Add Plot
|
||||||
|
</Button>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="primary"
|
||||||
|
on:click={() => (isScatterPlotConfigOpen = false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
50
web/frontend/src/PlotTable.svelte
Normal file
50
web/frontend/src/PlotTable.svelte
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<!--
|
||||||
|
@component
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
- itemsPerRow: Number
|
||||||
|
- items: [Any]
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export let itemsPerRow
|
||||||
|
export let items
|
||||||
|
export let padding = 10
|
||||||
|
|
||||||
|
let tableWidth = 0
|
||||||
|
const PLACEHOLDER = { magic: 'object' }
|
||||||
|
|
||||||
|
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(PLACEHOLDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
$: 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>
|
||||||
|
{#if item != PLACEHOLDER && plotWidth > 0}
|
||||||
|
<slot item={item} width={plotWidth}></slot>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</table>
|
122
web/frontend/src/StatsTable.svelte
Normal file
122
web/frontend/src/StatsTable.svelte
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<script>
|
||||||
|
import { getContext } from 'svelte'
|
||||||
|
import { Button, Table, InputGroup, InputGroupText, Icon } from 'sveltestrap'
|
||||||
|
import MetricSelection from './MetricSelection.svelte'
|
||||||
|
import StatsTableEntry from './StatsTableEntry.svelte'
|
||||||
|
import { maxScope } from './utils.js'
|
||||||
|
|
||||||
|
export let job
|
||||||
|
export let jobMetrics
|
||||||
|
|
||||||
|
const allMetrics = [...new Set(jobMetrics.map(m => m.name))].sort(),
|
||||||
|
scopesForMetric = (metric) => jobMetrics
|
||||||
|
.filter(jm => jm.name == metric)
|
||||||
|
.map(jm => jm.metric.scope)
|
||||||
|
|
||||||
|
let hosts = job.resources.map(r => r.hostname).sort(),
|
||||||
|
selectedScopes = {},
|
||||||
|
sorting = {},
|
||||||
|
isMetricSelectionOpen = false,
|
||||||
|
selectedMetrics = getContext('cc-config').job_view_nodestats_selectedMetrics
|
||||||
|
|
||||||
|
for (let metric of allMetrics) {
|
||||||
|
selectedScopes[metric] = maxScope(scopesForMetric(metric))
|
||||||
|
sorting[metric] = {
|
||||||
|
min: { dir: 'up', active: false },
|
||||||
|
avg: { dir: 'up', active: false },
|
||||||
|
max: { dir: 'up', active: false },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortBy(metric, stat) {
|
||||||
|
let s = sorting[metric][stat]
|
||||||
|
if (s.active) {
|
||||||
|
s.dir = s.dir == 'up' ? 'down' : 'up'
|
||||||
|
} else {
|
||||||
|
for (let metric in sorting)
|
||||||
|
for (let stat in sorting[metric])
|
||||||
|
sorting[metric][stat].active = false
|
||||||
|
s.active = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let series = jobMetrics.find(jm => jm.name == metric && jm.metric.scope == 'node')?.metric.series
|
||||||
|
sorting = {...sorting}
|
||||||
|
hosts = hosts.sort((h1, h2) => {
|
||||||
|
let s1 = series.find(s => s.hostname == h1)?.statistics
|
||||||
|
let s2 = series.find(s => s.hostname == h2)?.statistics
|
||||||
|
if (s1 == null || s2 == null)
|
||||||
|
return -1
|
||||||
|
|
||||||
|
return s.dir != 'up' ? s1[stat] - s2[stat] : s2[stat] - s1[stat]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moreLoaded(jobMetric) {
|
||||||
|
jobMetrics = [...jobMetrics, jobMetric]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<Button outline on:click={() => (isMetricSelectionOpen = true, console.log(isMetricSelectionOpen))}>
|
||||||
|
Metrics
|
||||||
|
</Button>
|
||||||
|
</th>
|
||||||
|
{#each selectedMetrics as metric}
|
||||||
|
<th colspan={selectedScopes[metric] == 'node' ? 3 : 4}>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupText>
|
||||||
|
{metric}
|
||||||
|
</InputGroupText>
|
||||||
|
<select class="form-select"
|
||||||
|
bind:value={selectedScopes[metric]}
|
||||||
|
disabled={scopesForMetric(metric, jobMetrics).length == 1}>
|
||||||
|
{#each scopesForMetric(metric, jobMetrics) as scope}
|
||||||
|
<option value={scope}>{scope}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Node</th>
|
||||||
|
{#each selectedMetrics as metric}
|
||||||
|
{#if selectedScopes[metric] != 'node'}
|
||||||
|
<th>Id</th>
|
||||||
|
{/if}
|
||||||
|
{#each ['min', 'avg', 'max'] as stat}
|
||||||
|
<th on:click={() => sortBy(metric, stat)}>
|
||||||
|
{stat}
|
||||||
|
{#if selectedScopes[metric] == 'node'}
|
||||||
|
<Icon name="caret-{sorting[metric][stat].dir}{sorting[metric][stat].active ? '-fill' : ''}" />
|
||||||
|
{/if}
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each hosts as host (host)}
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{host}</th>
|
||||||
|
{#each selectedMetrics as metric (metric)}
|
||||||
|
<StatsTableEntry
|
||||||
|
host={host} metric={metric}
|
||||||
|
scope={selectedScopes[metric]}
|
||||||
|
jobMetrics={jobMetrics} />
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<MetricSelection
|
||||||
|
configName='job_view_nodestats_selectedMetrics'
|
||||||
|
allMetrics={allMetrics}
|
||||||
|
bind:metrics={selectedMetrics}
|
||||||
|
bind:isOpen={isMetricSelectionOpen} />
|
37
web/frontend/src/StatsTableEntry.svelte
Normal file
37
web/frontend/src/StatsTableEntry.svelte
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<script>
|
||||||
|
export let host
|
||||||
|
export let metric
|
||||||
|
export let scope
|
||||||
|
export let jobMetrics
|
||||||
|
|
||||||
|
$: series = jobMetrics
|
||||||
|
.find(jm => jm.name == metric && jm.metric.scope == scope)
|
||||||
|
?.metric.series.filter(s => s.hostname == host && s.statistics != null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if series == null || series.length == 0}
|
||||||
|
<td colspan={scope == 'node' ? 3 : 4}><i>No data</i></td>
|
||||||
|
{:else if series.length == 1 && scope == 'node'}
|
||||||
|
<td>
|
||||||
|
{series[0].statistics.min}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{series[0].statistics.avg}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{series[0].statistics.max}
|
||||||
|
</td>
|
||||||
|
{:else}
|
||||||
|
<td colspan="4">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
{#each series as s, i}
|
||||||
|
<tr>
|
||||||
|
<th>{s.id ?? i}</th>
|
||||||
|
<td>{s.statistics.min}</td>
|
||||||
|
<td>{s.statistics.avg}</td>
|
||||||
|
<td>{s.statistics.max}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
{/if}
|
184
web/frontend/src/Status.root.svelte
Normal file
184
web/frontend/src/Status.root.svelte
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<script>
|
||||||
|
import Refresher from './joblist/Refresher.svelte'
|
||||||
|
import Roofline, { transformPerNodeData } from './plots/Roofline.svelte'
|
||||||
|
import Histogram from './plots/Histogram.svelte'
|
||||||
|
import { Row, Col, Spinner, Card, Table, Progress } from 'sveltestrap'
|
||||||
|
import { init } from './utils.js'
|
||||||
|
import { operationStore, query } from '@urql/svelte'
|
||||||
|
|
||||||
|
const { query: initq } = init()
|
||||||
|
|
||||||
|
export let cluster
|
||||||
|
|
||||||
|
let plotWidths = [], colWidth1 = 0, colWidth2
|
||||||
|
|
||||||
|
let from = new Date(Date.now() - 5 * 60 * 1000), to = new Date(Date.now())
|
||||||
|
const mainQuery = operationStore(`query($cluster: String!, $filter: [JobFilter!]!, $metrics: [String!], $from: Time!, $to: Time!) {
|
||||||
|
nodeMetrics(cluster: $cluster, metrics: $metrics, from: $from, to: $to) {
|
||||||
|
host,
|
||||||
|
subCluster,
|
||||||
|
metrics {
|
||||||
|
name,
|
||||||
|
metric {
|
||||||
|
scope
|
||||||
|
timestep,
|
||||||
|
series { data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats: jobsStatistics(filter: $filter) {
|
||||||
|
histDuration { count, value }
|
||||||
|
histNumNodes { count, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
allocatedNodes(cluster: $cluster) { name, count }
|
||||||
|
topUsers: jobsCount(filter: $filter, groupBy: USER, weight: NODE_COUNT, limit: 10) { name, count }
|
||||||
|
topProjects: jobsCount(filter: $filter, groupBy: PROJECT, weight: NODE_COUNT, limit: 10) { name, count }
|
||||||
|
}`, {
|
||||||
|
cluster: cluster,
|
||||||
|
metrics: ['flops_any', 'mem_bw'],
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString(),
|
||||||
|
filter: [{ state: ['running'] }, { cluster: { eq: cluster } }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const sumUp = (data, subcluster, metric) => data.reduce((sum, node) => node.subCluster == subcluster
|
||||||
|
? sum + (node.metrics.find(m => m.name == metric)?.metric.series.reduce((sum, series) => sum + series.data[series.data.length - 1], 0) || 0)
|
||||||
|
: sum, 0)
|
||||||
|
|
||||||
|
let allocatedNodes = {}, flopRate = {}, memBwRate = {}
|
||||||
|
$: if ($initq.data && $mainQuery.data) {
|
||||||
|
let subClusters = $initq.data.clusters.find(c => c.name == cluster).subClusters
|
||||||
|
for (let subCluster of subClusters) {
|
||||||
|
allocatedNodes[subCluster.name] = $mainQuery.data.allocatedNodes.find(({ name }) => name == subCluster.name)?.count || 0
|
||||||
|
flopRate[subCluster.name] = Math.floor(sumUp($mainQuery.data.nodeMetrics, subCluster.name, 'flops_any') * 100) / 100
|
||||||
|
memBwRate[subCluster.name] = Math.floor(sumUp($mainQuery.data.nodeMetrics, subCluster.name, 'mem_bw') * 100) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query(mainQuery)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col xs="auto">
|
||||||
|
{#if $initq.fetching || $mainQuery.fetching}
|
||||||
|
<Spinner/>
|
||||||
|
{:else if $initq.error}
|
||||||
|
<Card body color="danger">{$initq.error.message}</Card>
|
||||||
|
{:else}
|
||||||
|
<!-- ... -->
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
<Col xs="auto" style="margin-left: auto;">
|
||||||
|
<Refresher initially={120} on:reload={() => {
|
||||||
|
console.log('reload...')
|
||||||
|
|
||||||
|
from = new Date(Date.now() - 5 * 60 * 1000)
|
||||||
|
to = new Date(Date.now())
|
||||||
|
|
||||||
|
$mainQuery.variables = { ...$mainQuery.variables, from: from, to: to }
|
||||||
|
$mainQuery.reexecute({ requestPolicy: 'network-only' })
|
||||||
|
}} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{#if $mainQuery.error}
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<Card body color="danger">{$mainQuery.error.message}</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
||||||
|
{#if $initq.data && $mainQuery.data}
|
||||||
|
{#each $initq.data.clusters.find(c => c.name == cluster).subClusters as subCluster, i}
|
||||||
|
<Row>
|
||||||
|
<Col xs="3">
|
||||||
|
<Table>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">SubCluster</th>
|
||||||
|
<td colspan="2">{subCluster.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Allocated Nodes</th>
|
||||||
|
<td style="min-width: 75px;"><div class="col"><Progress value={allocatedNodes[subCluster.name]} max={subCluster.numberOfNodes}/></div></td>
|
||||||
|
<td>({allocatedNodes[subCluster.name]} / {subCluster.numberOfNodes})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Flop Rate</th>
|
||||||
|
<td style="min-width: 75px;"><div class="col"><Progress value={flopRate[subCluster.name]} max={subCluster.flopRateSimd * subCluster.numberOfNodes}/></div></td>
|
||||||
|
<td>({flopRate[subCluster.name]} / {subCluster.flopRateSimd * subCluster.numberOfNodes})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">MemBw Rate</th>
|
||||||
|
<td style="min-width: 75px;"><div class="col"><Progress value={memBwRate[subCluster.name]} max={subCluster.memoryBandwidth * subCluster.numberOfNodes}/></div></td>
|
||||||
|
<td>({memBwRate[subCluster.name]} / {subCluster.memoryBandwidth * subCluster.numberOfNodes})</td>
|
||||||
|
</tr>
|
||||||
|
</Table>
|
||||||
|
</Col>
|
||||||
|
<div class="col-9" bind:clientWidth={plotWidths[i]}>
|
||||||
|
{#key $mainQuery.data.nodeMetrics}
|
||||||
|
<Roofline
|
||||||
|
width={plotWidths[i] - 10} height={300} colorDots={false} cluster={subCluster}
|
||||||
|
data={transformPerNodeData($mainQuery.data.nodeMetrics.filter(data => data.subCluster == subCluster.name))} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
{/each}
|
||||||
|
<Row>
|
||||||
|
<div class="col-4" bind:clientWidth={colWidth1}>
|
||||||
|
<h4>Top Users</h4>
|
||||||
|
{#key $mainQuery.data}
|
||||||
|
<Histogram
|
||||||
|
width={colWidth1 - 25} height={300}
|
||||||
|
data={$mainQuery.data.topUsers.sort((a, b) => b.count - a.count).map(({ count }, idx) => ({ count, value: idx }))}
|
||||||
|
label={(x) => x < $mainQuery.data.topUsers.length ? $mainQuery.data.topUsers[Math.floor(x)].name : '0'} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<Table>
|
||||||
|
<tr><th>Name</th><th>Number of Nodes</th></tr>
|
||||||
|
{#each $mainQuery.data.topUsers.sort((a, b) => b.count - a.count) as { name, count }}
|
||||||
|
<tr>
|
||||||
|
<th scope="col"><a href="/monitoring/user/{name}">{name}</a></th>
|
||||||
|
<td>{count}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<h4>Top Projects</h4>
|
||||||
|
{#key $mainQuery.data}
|
||||||
|
<Histogram
|
||||||
|
width={colWidth1 - 25} height={300}
|
||||||
|
data={$mainQuery.data.topProjects.sort((a, b) => b.count - a.count).map(({ count }, idx) => ({ count, value: idx }))}
|
||||||
|
label={(x) => x < $mainQuery.data.topProjects.length ? $mainQuery.data.topProjects[Math.floor(x)].name : '0'} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<Table>
|
||||||
|
<tr><th>Name</th><th>Number of Nodes</th></tr>
|
||||||
|
{#each $mainQuery.data.topProjects.sort((a, b) => b.count - a.count) as { name, count }}
|
||||||
|
<tr><th scope="col">{name}</th><td>{count}</td></tr>
|
||||||
|
{/each}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<div class="col" bind:clientWidth={colWidth2}>
|
||||||
|
<h4>Duration Distribution</h4>
|
||||||
|
{#key $mainQuery.data.stats}
|
||||||
|
<Histogram
|
||||||
|
width={colWidth2 - 25} height={300}
|
||||||
|
data={$mainQuery.data.stats[0].histDuration} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<h4>Number of Nodes Distribution</h4>
|
||||||
|
{#key $mainQuery.data.stats}
|
||||||
|
<Histogram
|
||||||
|
width={colWidth2 - 25} height={300}
|
||||||
|
data={$mainQuery.data.stats[0].histNumNodes} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
{/if}
|
118
web/frontend/src/Systems.root.svelte
Normal file
118
web/frontend/src/Systems.root.svelte
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<script>
|
||||||
|
import { init } from './utils.js'
|
||||||
|
import { Row, Col, Input, InputGroup, InputGroupText, Icon, Spinner, Card } from 'sveltestrap'
|
||||||
|
import { operationStore, query } from '@urql/svelte'
|
||||||
|
import TimeSelection from './filters/TimeSelection.svelte'
|
||||||
|
import PlotTable from './PlotTable.svelte'
|
||||||
|
import MetricPlot from './plots/MetricPlot.svelte'
|
||||||
|
import { getContext } from 'svelte'
|
||||||
|
|
||||||
|
export let cluster
|
||||||
|
export let from = null
|
||||||
|
export let to = null
|
||||||
|
|
||||||
|
const { query: initq } = init()
|
||||||
|
|
||||||
|
if (from == null || to == null) {
|
||||||
|
to = new Date(Date.now())
|
||||||
|
from = new Date(to.getTime())
|
||||||
|
from.setMinutes(from.getMinutes() - 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusters = getContext('clusters')
|
||||||
|
const ccconfig = getContext('cc-config')
|
||||||
|
|
||||||
|
let plotHeight = 300
|
||||||
|
let hostnameFilter = ''
|
||||||
|
let selectedMetric = ccconfig.system_view_selectedMetric
|
||||||
|
|
||||||
|
const nodesQuery = operationStore(`query($cluster: String!, $metrics: [String!], $from: Time!, $to: Time!) {
|
||||||
|
nodeMetrics(cluster: $cluster, metrics: $metrics, from: $from, to: $to) {
|
||||||
|
host,
|
||||||
|
subCluster
|
||||||
|
metrics {
|
||||||
|
name,
|
||||||
|
metric {
|
||||||
|
scope
|
||||||
|
timestep,
|
||||||
|
series {
|
||||||
|
statistics { min, avg, max }
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`, {
|
||||||
|
cluster: cluster,
|
||||||
|
metrics: [],
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
$: $nodesQuery.variables = { cluster, metrics: [selectedMetric], from: from.toISOString(), to: to.toISOString() }
|
||||||
|
|
||||||
|
query(nodesQuery)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
{#if $initq.error}
|
||||||
|
<Card body color="danger">{$initq.error.message}</Card>
|
||||||
|
{:else if $initq.fetching}
|
||||||
|
<Spinner/>
|
||||||
|
{:else}
|
||||||
|
<Col>
|
||||||
|
<TimeSelection
|
||||||
|
bind:from={from}
|
||||||
|
bind:to={to} />
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupText><Icon name="graph-up" /></InputGroupText>
|
||||||
|
<InputGroupText>Metric</InputGroupText>
|
||||||
|
<select class="form-select" bind:value={selectedMetric}>
|
||||||
|
{#each clusters.find(c => c.name == cluster).metricConfig as metric}
|
||||||
|
<option value={metric.name}>{metric.name} ({metric.unit})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupText><Icon name="hdd" /></InputGroupText>
|
||||||
|
<InputGroupText>Find Node</InputGroupText>
|
||||||
|
<Input placeholder="hostname..." type="text" bind:value={hostnameFilter} />
|
||||||
|
</InputGroup>
|
||||||
|
</Col>
|
||||||
|
{/if}
|
||||||
|
</Row>
|
||||||
|
<br/>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
{#if $nodesQuery.error}
|
||||||
|
<Card body color="danger">{$nodesQuery.error.message}</Card>
|
||||||
|
{:else if $nodesQuery.fetching || $initq.fetching}
|
||||||
|
<Spinner/>
|
||||||
|
{:else}
|
||||||
|
<PlotTable
|
||||||
|
let:item
|
||||||
|
let:width
|
||||||
|
itemsPerRow={ccconfig.plot_view_plotsPerRow}
|
||||||
|
items={$nodesQuery.data.nodeMetrics
|
||||||
|
.filter(h => h.host.includes(hostnameFilter) && h.metrics.some(m => m.name == selectedMetric && m.metric.scope == 'node'))
|
||||||
|
.map(h => ({ host: h.host, subCluster: h.subCluster, data: h.metrics.find(m => m.name == selectedMetric && m.metric.scope == 'node') }))
|
||||||
|
.sort((a, b) => a.host.localeCompare(b.host))}>
|
||||||
|
|
||||||
|
<h4 style="width: 100%; text-align: center;"><a href="/monitoring/node/{cluster}/{item.host}">{item.host} ({item.subCluster})</a></h4>
|
||||||
|
<MetricPlot
|
||||||
|
width={width}
|
||||||
|
height={plotHeight}
|
||||||
|
timestep={item.data.metric.timestep}
|
||||||
|
series={item.data.metric.series}
|
||||||
|
metric={item.data.name}
|
||||||
|
cluster={clusters.find(c => c.name == cluster)}
|
||||||
|
subCluster={item.subCluster} />
|
||||||
|
</PlotTable>
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
44
web/frontend/src/Tag.svelte
Normal file
44
web/frontend/src/Tag.svelte
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<!--
|
||||||
|
@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>
|
173
web/frontend/src/TagManagement.svelte
Normal file
173
web/frontend/src/TagManagement.svelte
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
<script>
|
||||||
|
import { getContext } from 'svelte'
|
||||||
|
import { mutation } from '@urql/svelte'
|
||||||
|
import { Icon, Button, ListGroupItem, Spinner, Modal, Input,
|
||||||
|
ModalBody, ModalHeader, ModalFooter, Alert } from 'sveltestrap'
|
||||||
|
import { fuzzySearchTags } from './utils.js'
|
||||||
|
import Tag from './Tag.svelte'
|
||||||
|
|
||||||
|
export let job
|
||||||
|
export let jobTags = job.tags
|
||||||
|
|
||||||
|
let allTags = getContext('tags'), initialized = getContext('initialized')
|
||||||
|
let newTagType = '', newTagName = ''
|
||||||
|
let filterTerm = ''
|
||||||
|
let pendingChange = false
|
||||||
|
let isOpen = false
|
||||||
|
|
||||||
|
const createTagMutation = mutation({
|
||||||
|
query: `mutation($type: String!, $name: String!) {
|
||||||
|
createTag(type: $type, name: $name) { id, type, name }
|
||||||
|
}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const addTagsToJobMutation = mutation({
|
||||||
|
query: `mutation($job: ID!, $tagIds: [ID!]!) {
|
||||||
|
addTagsToJob(job: $job, tagIds: $tagIds) { id, type, name }
|
||||||
|
}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeTagsFromJobMutation = mutation({
|
||||||
|
query: `mutation($job: ID!, $tagIds: [ID!]!) {
|
||||||
|
removeTagsFromJob(job: $job, tagIds: $tagIds) { id, type, name }
|
||||||
|
}`
|
||||||
|
})
|
||||||
|
|
||||||
|
let allTagsFiltered // $initialized is in there because when it becomes true, allTags is initailzed.
|
||||||
|
$: allTagsFiltered = ($initialized, fuzzySearchTags(filterTerm, allTags))
|
||||||
|
|
||||||
|
$: {
|
||||||
|
newTagType = '';
|
||||||
|
newTagName = '';
|
||||||
|
let parts = filterTerm.split(':').map(s => s.trim())
|
||||||
|
if (parts.length == 2 && parts.every(s => s.length > 0)) {
|
||||||
|
newTagType = parts[0]
|
||||||
|
newTagName = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewTag(type, name) {
|
||||||
|
for (let tag of allTagsFiltered)
|
||||||
|
if (tag.type == type && tag.name == name)
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTag(type, name) {
|
||||||
|
pendingChange = true
|
||||||
|
return createTagMutation({ type: type, name: name })
|
||||||
|
.then(res => {
|
||||||
|
if (res.error)
|
||||||
|
throw res.error
|
||||||
|
|
||||||
|
pendingChange = false
|
||||||
|
allTags = [...allTags, res.data.createTag]
|
||||||
|
newTagType = ''
|
||||||
|
newTagName = ''
|
||||||
|
return res.data.createTag
|
||||||
|
}, err => console.error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTagToJob(tag) {
|
||||||
|
pendingChange = tag.id
|
||||||
|
addTagsToJobMutation({ job: job.id, tagIds: [tag.id] })
|
||||||
|
.then(res => {
|
||||||
|
if (res.error)
|
||||||
|
throw res.error
|
||||||
|
|
||||||
|
jobTags = job.tags = res.data.addTagsToJob;
|
||||||
|
pendingChange = false;
|
||||||
|
})
|
||||||
|
.catch(err => console.error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTagFromJob(tag) {
|
||||||
|
pendingChange = tag.id
|
||||||
|
removeTagsFromJobMutation({ job: job.id, tagIds: [tag.id] })
|
||||||
|
.then(res => {
|
||||||
|
if (res.error)
|
||||||
|
throw res.error
|
||||||
|
|
||||||
|
jobTags = job.tags = res.data.removeTagsFromJob
|
||||||
|
pendingChange = false
|
||||||
|
})
|
||||||
|
.catch(err => console.error(err))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
ul.list-group {
|
||||||
|
max-height: 450px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
|
||||||
|
<ModalHeader>
|
||||||
|
Manage Tags
|
||||||
|
{#if pendingChange !== false}
|
||||||
|
<Spinner size="sm" secondary />
|
||||||
|
{:else}
|
||||||
|
<Icon name="tags" />
|
||||||
|
{/if}
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<Input style="width: 100%;"
|
||||||
|
type="text" placeholder="Search Tags"
|
||||||
|
bind:value={filterTerm} />
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<Alert color="info">
|
||||||
|
Search using "<code>type: name</code>". If no tag matches your search,
|
||||||
|
a button for creating a new one will appear.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<ul class="list-group">
|
||||||
|
{#each allTagsFiltered as tag}
|
||||||
|
<ListGroupItem>
|
||||||
|
<Tag tag={tag}/>
|
||||||
|
|
||||||
|
<span style="float: right;">
|
||||||
|
{#if pendingChange === tag.id}
|
||||||
|
<Spinner size="sm" secondary />
|
||||||
|
{:else if job.tags.find(t => t.id == tag.id)}
|
||||||
|
<Button size="sm" outline color="danger"
|
||||||
|
on:click={() => removeTagFromJob(tag)}>
|
||||||
|
<Icon name="x" />
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button size="sm" outline color="success"
|
||||||
|
on:click={() => addTagToJob(tag)}>
|
||||||
|
<Icon name="plus" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</ListGroupItem>
|
||||||
|
{:else}
|
||||||
|
<ListGroupItem disabled>
|
||||||
|
<i>No tags matching</i>
|
||||||
|
</ListGroupItem>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<br/>
|
||||||
|
{#if newTagType && newTagName && isNewTag(newTagType, newTagName)}
|
||||||
|
<Button outline color="success"
|
||||||
|
on:click={e => (e.preventDefault(), createTag(newTagType, newTagName))
|
||||||
|
.then(tag => addTagToJob(tag))}>
|
||||||
|
Create & Add Tag:
|
||||||
|
<Tag tag={({ type: newTagType, name: newTagName })} clickable={false}/>
|
||||||
|
</Button>
|
||||||
|
{:else if allTagsFiltered.length == 0}
|
||||||
|
<Alert>Search Term is not a valid Tag (<code>type: name</code>)</Alert>
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="primary" on:click={() => (isOpen = false)}>Close</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Button outline on:click={() => (isOpen = true)}>
|
||||||
|
Manage Tags <Icon name="tags" />
|
||||||
|
</Button>
|
172
web/frontend/src/User.root.svelte
Normal file
172
web/frontend/src/User.root.svelte
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, getContext } from 'svelte'
|
||||||
|
import { init } from './utils.js'
|
||||||
|
import { Table, Row, Col, Button, Icon, Card, Spinner } from 'sveltestrap'
|
||||||
|
import { operationStore, query } from '@urql/svelte'
|
||||||
|
import Filters from './filters/Filters.svelte'
|
||||||
|
import JobList from './joblist/JobList.svelte'
|
||||||
|
import Sorting from './joblist/SortSelection.svelte'
|
||||||
|
import Refresher from './joblist/Refresher.svelte'
|
||||||
|
import Histogram from './plots/Histogram.svelte'
|
||||||
|
import MetricSelection from './MetricSelection.svelte'
|
||||||
|
import { scramble, scrambleNames } from './joblist/JobInfo.svelte'
|
||||||
|
|
||||||
|
const { query: initq } = init()
|
||||||
|
|
||||||
|
const ccconfig = getContext('cc-config')
|
||||||
|
|
||||||
|
export let user
|
||||||
|
export let filterPresets
|
||||||
|
|
||||||
|
let filters, jobList
|
||||||
|
let sorting = { field: 'startTime', order: 'DESC' }, isSortingOpen = false
|
||||||
|
let metrics = ccconfig.plot_list_selectedMetrics, isMetricsSelectionOpen = false
|
||||||
|
let w1, w2, histogramHeight = 250
|
||||||
|
|
||||||
|
const stats = operationStore(`
|
||||||
|
query($filter: [JobFilter!]!) {
|
||||||
|
jobsStatistics(filter: $filter) {
|
||||||
|
totalJobs
|
||||||
|
shortJobs
|
||||||
|
totalWalltime
|
||||||
|
totalCoreHours
|
||||||
|
histDuration { count, value }
|
||||||
|
histNumNodes { count, value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, {
|
||||||
|
filter: []
|
||||||
|
}, {
|
||||||
|
pause: true
|
||||||
|
})
|
||||||
|
|
||||||
|
query(stats)
|
||||||
|
|
||||||
|
onMount(() => filters.update())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
{#if $initq.fetching}
|
||||||
|
<Col>
|
||||||
|
<Spinner/>
|
||||||
|
</Col>
|
||||||
|
{:else if $initq.error}
|
||||||
|
<Col xs="auto">
|
||||||
|
<Card body color="danger">{$initq.error.message}</Card>
|
||||||
|
</Col>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Col xs="auto">
|
||||||
|
<Button
|
||||||
|
outline color="primary"
|
||||||
|
on:click={() => (isSortingOpen = true)}>
|
||||||
|
<Icon name="sort-up"/> Sorting
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
outline color="primary"
|
||||||
|
on:click={() => (isMetricsSelectionOpen = true)}>
|
||||||
|
<Icon name="graph-up"/> Metrics
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col xs="auto">
|
||||||
|
<Filters
|
||||||
|
filterPresets={filterPresets}
|
||||||
|
startTimeQuickSelect={true}
|
||||||
|
bind:this={filters}
|
||||||
|
on:update={({ detail }) => {
|
||||||
|
let filters = [...detail.filters, { user: { eq: user.username } }]
|
||||||
|
$stats.variables = { filter: filters }
|
||||||
|
$stats.context.pause = false
|
||||||
|
$stats.reexecute()
|
||||||
|
jobList.update(filters)
|
||||||
|
}} />
|
||||||
|
</Col>
|
||||||
|
<Col xs="auto" style="margin-left: auto;">
|
||||||
|
<Refresher on:reload={() => jobList.update()} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<br/>
|
||||||
|
<Row>
|
||||||
|
{#if $stats.error}
|
||||||
|
<Col>
|
||||||
|
<Card body color="danger">{$stats.error.message}</Card>
|
||||||
|
</Col>
|
||||||
|
{:else if !$stats.data}
|
||||||
|
<Col>
|
||||||
|
<Spinner secondary />
|
||||||
|
</Col>
|
||||||
|
{:else}
|
||||||
|
<Col xs="4">
|
||||||
|
<Table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Username</th>
|
||||||
|
<td>{scrambleNames ? scramble(user.username) : user.username}</td>
|
||||||
|
</tr>
|
||||||
|
{#if user.name}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Name</th>
|
||||||
|
<td>{scrambleNames ? scramble(user.name) : user.name}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if user.email}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Email</th>
|
||||||
|
<td>{user.email}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Total Jobs</th>
|
||||||
|
<td>{$stats.data.jobsStatistics[0].totalJobs}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Short Jobs</th>
|
||||||
|
<td>{$stats.data.jobsStatistics[0].shortJobs}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Total Walltime</th>
|
||||||
|
<td>{$stats.data.jobsStatistics[0].totalWalltime}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Total Core Hours</th>
|
||||||
|
<td>{$stats.data.jobsStatistics[0].totalCoreHours}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</Col>
|
||||||
|
<div class="col-4" style="text-align: center;" bind:clientWidth={w1}>
|
||||||
|
<b>Walltime</b>
|
||||||
|
{#key $stats.data.jobsStatistics[0].histDuration}
|
||||||
|
<Histogram
|
||||||
|
data={$stats.data.jobsStatistics[0].histDuration}
|
||||||
|
width={w1 - 25} height={histogramHeight} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
<div class="col-4" style="text-align: center;" bind:clientWidth={w2}>
|
||||||
|
<b>Number of Nodes</b>
|
||||||
|
{#key $stats.data.jobsStatistics[0].histNumNodes}
|
||||||
|
<Histogram
|
||||||
|
data={$stats.data.jobsStatistics[0].histNumNodes}
|
||||||
|
width={w2 - 25} height={histogramHeight} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Row>
|
||||||
|
<br/>
|
||||||
|
<Row>
|
||||||
|
<Col>
|
||||||
|
<JobList
|
||||||
|
bind:metrics={metrics}
|
||||||
|
bind:sorting={sorting}
|
||||||
|
bind:this={jobList} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Sorting
|
||||||
|
bind:sorting={sorting}
|
||||||
|
bind:isOpen={isSortingOpen} />
|
||||||
|
|
||||||
|
<MetricSelection configName="plot_list_selectedMetrics"
|
||||||
|
bind:metrics={metrics}
|
||||||
|
bind:isOpen={isMetricsSelectionOpen} />
|
60
web/frontend/src/Zoom.svelte
Normal file
60
web/frontend/src/Zoom.svelte
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<script>
|
||||||
|
import { Icon, InputGroup, InputGroupText } from 'sveltestrap';
|
||||||
|
|
||||||
|
export let timeseriesPlots;
|
||||||
|
|
||||||
|
let windowSize = 100; // Goes from 0 to 100
|
||||||
|
let windowPosition = 50; // Goes from 0 to 100
|
||||||
|
|
||||||
|
function updatePlots() {
|
||||||
|
let ws = windowSize / (100 * 2),
|
||||||
|
wp = windowPosition / 100;
|
||||||
|
let from = (wp - ws),
|
||||||
|
to = (wp + ws);
|
||||||
|
Object
|
||||||
|
.values(timeseriesPlots)
|
||||||
|
.forEach(plot => plot.setTimeRange(from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendering a big job can take a long time, so we
|
||||||
|
// throttle the rerenders to every 100ms here.
|
||||||
|
let timeoutId = null;
|
||||||
|
function requestUpdatePlots() {
|
||||||
|
if (timeoutId != null)
|
||||||
|
window.cancelAnimationFrame(timeoutId);
|
||||||
|
|
||||||
|
timeoutId = window.requestAnimationFrame(() => {
|
||||||
|
updatePlots();
|
||||||
|
timeoutId = null;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: requestUpdatePlots(windowSize, windowPosition);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupText>
|
||||||
|
<Icon name="zoom-in"/>
|
||||||
|
</InputGroupText>
|
||||||
|
<InputGroupText>
|
||||||
|
Window Size:
|
||||||
|
<input
|
||||||
|
style="margin: 0em 0em 0em 1em"
|
||||||
|
type="range"
|
||||||
|
bind:value={windowSize}
|
||||||
|
min=1 max=100 step=1 />
|
||||||
|
<span style="width: 5em;">
|
||||||
|
({windowSize}%)
|
||||||
|
</span>
|
||||||
|
</InputGroupText>
|
||||||
|
<InputGroupText>
|
||||||
|
Window Position:
|
||||||
|
<input
|
||||||
|
style="margin: 0em 0em 0em 1em"
|
||||||
|
type="range"
|
||||||
|
bind:value={windowPosition}
|
||||||
|
min=0 max=100 step=1 />
|
||||||
|
</InputGroupText>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
14
web/frontend/src/analysis.entrypoint.js
Normal file
14
web/frontend/src/analysis.entrypoint.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import {} from './header.entrypoint.js'
|
||||||
|
import Analysis from './Analysis.root.svelte'
|
||||||
|
|
||||||
|
filterPresets.cluster = cluster
|
||||||
|
|
||||||
|
new Analysis({
|
||||||
|
target: document.getElementById('svelte-app'),
|
||||||
|
props: {
|
||||||
|
filterPresets: filterPresets
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
['cc-config', clusterCockpitConfig]
|
||||||
|
])
|
||||||
|
})
|
72
web/frontend/src/cache-exchange.js
Normal file
72
web/frontend/src/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
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
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>
|
10
web/frontend/src/header.entrypoint.js
Normal file
10
web/frontend/src/header.entrypoint.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import Header from './Header.svelte'
|
||||||
|
|
||||||
|
const headerDomTarget = document.getElementById('svelte-header')
|
||||||
|
|
||||||
|
if (headerDomTarget != null) {
|
||||||
|
new Header({
|
||||||
|
target: headerDomTarget,
|
||||||
|
props: { ...header },
|
||||||
|
})
|
||||||
|
}
|
12
web/frontend/src/job.entrypoint.js
Normal file
12
web/frontend/src/job.entrypoint.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import {} from './header.entrypoint.js'
|
||||||
|
import Job from './Job.root.svelte'
|
||||||
|
|
||||||
|
new Job({
|
||||||
|
target: document.getElementById('svelte-app'),
|
||||||
|
props: {
|
||||||
|
dbid: jobInfos.id
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
['cc-config', clusterCockpitConfig]
|
||||||
|
])
|
||||||
|
})
|
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>
|
12
web/frontend/src/jobs.entrypoint.js
Normal file
12
web/frontend/src/jobs.entrypoint.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import {} from './header.entrypoint.js'
|
||||||
|
import Jobs from './Jobs.root.svelte'
|
||||||
|
|
||||||
|
new Jobs({
|
||||||
|
target: document.getElementById('svelte-app'),
|
||||||
|
props: {
|
||||||
|
filterPresets: filterPresets
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
['cc-config', clusterCockpitConfig]
|
||||||
|
])
|
||||||
|
})
|
13
web/frontend/src/list.entrypoint.js
Normal file
13
web/frontend/src/list.entrypoint.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import {} from './header.entrypoint.js'
|
||||||
|
import List from './List.root.svelte'
|
||||||
|
|
||||||
|
new List({
|
||||||
|
target: document.getElementById('svelte-app'),
|
||||||
|
props: {
|
||||||
|
filterPresets: filterPresets,
|
||||||
|
type: listType,
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
['cc-config', clusterCockpitConfig]
|
||||||
|
])
|
||||||
|
})
|
15
web/frontend/src/node.entrypoint.js
Normal file
15
web/frontend/src/node.entrypoint.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {} from './header.entrypoint.js'
|
||||||
|
import Node from './Node.root.svelte'
|
||||||
|
|
||||||
|
new Node({
|
||||||
|
target: document.getElementById('svelte-app'),
|
||||||
|
props: {
|
||||||
|
cluster: infos.cluster,
|
||||||
|
hostname: infos.hostname,
|
||||||
|
from: infos.from,
|
||||||
|
to: infos.to
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
['cc-config', clusterCockpitConfig]
|
||||||
|
])
|
||||||
|
})
|
210
web/frontend/src/plots/Histogram.svelte
Normal file
210
web/frontend/src/plots/Histogram.svelte
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
<!--
|
||||||
|
@component
|
||||||
|
Properties:
|
||||||
|
- width, height: Number
|
||||||
|
- min, max: Number
|
||||||
|
- label: (x-Value) => String
|
||||||
|
- data: [{ value: Number, count: Number }]
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div
|
||||||
|
on:mousemove={mousemove}
|
||||||
|
on:mouseleave={() => (infoText = '')}>
|
||||||
|
<span style="left: {paddingLeft + 5}px;">{infoText}</span>
|
||||||
|
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
export let data
|
||||||
|
export let width
|
||||||
|
export let height
|
||||||
|
export let min = null
|
||||||
|
export let max = null
|
||||||
|
export let label = formatNumber
|
||||||
|
|
||||||
|
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 = 35, paddingRight = 20, paddingTop = 20, paddingBottom = 20
|
||||||
|
|
||||||
|
let ctx, canvasElement
|
||||||
|
|
||||||
|
const maxCount = data.reduce((max, point) => Math.max(max, point.count), 0),
|
||||||
|
maxValue = data.reduce((max, point) => Math.max(max, point.value), 0.1)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let infoText = ''
|
||||||
|
function mousemove(event) {
|
||||||
|
let rect = event.target.getBoundingClientRect()
|
||||||
|
let x = event.clientX - rect.left
|
||||||
|
if (x < paddingLeft || x > width - paddingRight) {
|
||||||
|
infoText = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = width - paddingLeft - paddingRight
|
||||||
|
const barWidth = Math.round(w / (maxValue + 1))
|
||||||
|
x = Math.floor((x - paddingLeft) / (w - barWidth) * maxValue)
|
||||||
|
let point = data.find(point => point.value == x)
|
||||||
|
|
||||||
|
if (point)
|
||||||
|
infoText = `count: ${point.count} (value: ${label(x)})`
|
||||||
|
else
|
||||||
|
infoText = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const h = height - paddingTop - paddingBottom
|
||||||
|
const w = width - paddingLeft - paddingRight
|
||||||
|
const barWidth = Math.ceil(w / (maxValue + 1))
|
||||||
|
|
||||||
|
if (Number.isNaN(barWidth))
|
||||||
|
return
|
||||||
|
|
||||||
|
const getCanvasX = (value) => (value / maxValue) * (w - barWidth) + paddingLeft + (barWidth / 2.)
|
||||||
|
const getCanvasY = (count) => (h - (count / maxCount) * h) + paddingTop
|
||||||
|
|
||||||
|
// X Axis
|
||||||
|
ctx.font = `${fontSize}px ${fontFamily}`
|
||||||
|
ctx.fillStyle = 'black'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
if (min != null && max != null) {
|
||||||
|
const stepsizeX = getStepSize(max - min, w, 75)
|
||||||
|
let startX = 0
|
||||||
|
while (startX < min)
|
||||||
|
startX += stepsizeX
|
||||||
|
|
||||||
|
for (let x = startX; x < max; x += stepsizeX) {
|
||||||
|
let px = ((x - min) / (max - min)) * (w - barWidth) + paddingLeft + (barWidth / 2.)
|
||||||
|
ctx.fillText(`${formatNumber(x)}`, px, height - paddingBottom + 15)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const stepsizeX = getStepSize(maxValue, w, 120)
|
||||||
|
for (let x = 0; x <= maxValue; x += stepsizeX) {
|
||||||
|
ctx.fillText(label(x), getCanvasX(x), height - paddingBottom + 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Y Axis
|
||||||
|
ctx.fillStyle = 'black'
|
||||||
|
ctx.strokeStyle = '#bbbbbb'
|
||||||
|
ctx.textAlign = 'right'
|
||||||
|
ctx.beginPath()
|
||||||
|
const stepsizeY = getStepSize(maxCount, h, 50)
|
||||||
|
for (let y = stepsizeY; y <= maxCount; y += stepsizeY) {
|
||||||
|
const py = Math.floor(getCanvasY(y))
|
||||||
|
ctx.fillText(`${formatNumber(y)}`, paddingLeft - 5, py)
|
||||||
|
ctx.moveTo(paddingLeft, py)
|
||||||
|
ctx.lineTo(width, py)
|
||||||
|
}
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Draw bars
|
||||||
|
ctx.fillStyle = '#0066cc'
|
||||||
|
for (let p of data) {
|
||||||
|
ctx.fillRect(
|
||||||
|
getCanvasX(p.value) - (barWidth / 2.),
|
||||||
|
getCanvasY(p.count),
|
||||||
|
barWidth,
|
||||||
|
(p.count / maxCount) * h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fat lines left and below plotting area
|
||||||
|
ctx.strokeStyle = 'black'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, height - paddingBottom)
|
||||||
|
ctx.lineTo(width, height - paddingBottom)
|
||||||
|
ctx.moveTo(paddingLeft, 0)
|
||||||
|
ctx.lineTo(paddingLeft, height- paddingBottom)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = false
|
||||||
|
onMount(() => {
|
||||||
|
mounted = true
|
||||||
|
canvasElement.width = width
|
||||||
|
canvasElement.height = height
|
||||||
|
ctx = canvasElement.getContext('2d')
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
$: sizeChanged(width, height)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
div {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
div > span {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script context="module">
|
||||||
|
import { formatNumber } from '../utils.js'
|
||||||
|
|
||||||
|
export function binsFromFootprint(weights, values, numBins) {
|
||||||
|
let min = 0, max = 0
|
||||||
|
if (values.length != 0) {
|
||||||
|
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
|
||||||
|
|
||||||
|
const bins = new Array(numBins).fill(0)
|
||||||
|
for (let i = 0; i < values.length; i++)
|
||||||
|
bins[Math.floor(((values[i] - min) / (max - min)) * numBins)] += weights ? weights[i] : 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: idx => {
|
||||||
|
let start = min + (idx / numBins) * (max - min)
|
||||||
|
let stop = min + ((idx + 1) / numBins) * (max - min)
|
||||||
|
return `${formatNumber(start)} - ${formatNumber(stop)}`
|
||||||
|
},
|
||||||
|
bins: bins.map((count, idx) => ({ value: idx, count: count })),
|
||||||
|
min: min,
|
||||||
|
max: max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
306
web/frontend/src/plots/MetricPlot.svelte
Normal file
306
web/frontend/src/plots/MetricPlot.svelte
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
<!--
|
||||||
|
@component
|
||||||
|
|
||||||
|
Only width/height should change reactively.
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
- width: Number
|
||||||
|
- height: Number
|
||||||
|
- timestep: Number
|
||||||
|
- series: [GraphQL.Series]
|
||||||
|
- statisticsSeries: [GraphQL.StatisticsSeries]
|
||||||
|
- cluster: GraphQL.Cluster
|
||||||
|
- subCluster: String
|
||||||
|
- metric: String
|
||||||
|
- scope: String
|
||||||
|
- useStatsSeries: Boolean
|
||||||
|
|
||||||
|
Functions:
|
||||||
|
- setTimeRange(from, to): Void
|
||||||
|
|
||||||
|
// TODO: Move helper functions to module context?
|
||||||
|
-->
|
||||||
|
<script>
|
||||||
|
import uPlot from 'uplot'
|
||||||
|
import { formatNumber } from '../utils.js'
|
||||||
|
import { getContext, onMount, onDestroy } from 'svelte'
|
||||||
|
|
||||||
|
export let width
|
||||||
|
export let height
|
||||||
|
export let timestep
|
||||||
|
export let series
|
||||||
|
export let statisticsSeries = null
|
||||||
|
export let cluster
|
||||||
|
export let subCluster
|
||||||
|
export let metric
|
||||||
|
export let useStatsSeries = null
|
||||||
|
export let scope = 'node'
|
||||||
|
|
||||||
|
if (useStatsSeries == null)
|
||||||
|
useStatsSeries = statisticsSeries != null
|
||||||
|
|
||||||
|
if (useStatsSeries == false && series == null)
|
||||||
|
useStatsSeries = true
|
||||||
|
|
||||||
|
const metricConfig = getContext('metrics')(cluster, 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(metricConfig, scope, typeof subCluster == 'string' ? cluster.subClusters.find(sc => sc.name == subCluster) : subCluster)
|
||||||
|
|
||||||
|
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.mean.length
|
||||||
|
: series.reduce((n, series) => Math.max(n, series.data.length), 0)
|
||||||
|
const maxX = longestSeries * timestep
|
||||||
|
const maxY = thresholds != null
|
||||||
|
? 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)
|
||||||
|
: null
|
||||||
|
const plotSeries = [{}]
|
||||||
|
const plotData = [new Array(longestSeries)]
|
||||||
|
for (let i = 0; i < longestSeries; i++) // TODO: Cache/Reuse this array?
|
||||||
|
plotData[0][i] = i * timestep
|
||||||
|
|
||||||
|
let plotBands = undefined
|
||||||
|
if (useStatsSeries) {
|
||||||
|
plotData.push(statisticsSeries.min)
|
||||||
|
plotData.push(statisticsSeries.max)
|
||||||
|
plotData.push(statisticsSeries.mean)
|
||||||
|
plotSeries.push({ scale: 'y', width: lineWidth, stroke: 'red' })
|
||||||
|
plotSeries.push({ scale: 'y', width: lineWidth, stroke: 'green' })
|
||||||
|
plotSeries.push({ scale: 'y', width: lineWidth, stroke: 'black' })
|
||||||
|
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)
|
||||||
|
plotSeries.push({
|
||||||
|
scale: 'y',
|
||||||
|
width: lineWidth,
|
||||||
|
stroke: lineColor(i, series.length)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
series: plotSeries,
|
||||||
|
axes: [
|
||||||
|
{
|
||||||
|
scale: 'x',
|
||||||
|
space: 35,
|
||||||
|
incrs: timeIncrs(timestep, maxX),
|
||||||
|
values: (_, vals) => vals.map(v => formatTime(v))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 text = `${scope}${plotSeries.length > 2 ? 's' : ''}${useStatsSeries ? ': min/avg/max' : ''}`
|
||||||
|
u.ctx.save()
|
||||||
|
u.ctx.textAlign = 'start' // 'end'
|
||||||
|
u.ctx.fillStyle = 'black'
|
||||||
|
u.ctx.fillText(text, u.bbox.left + 10, u.bbox.top + 10)
|
||||||
|
// u.ctx.fillText(text, u.bbox.left + u.bbox.width - 10, u.bbox.top + u.bbox.height - 10)
|
||||||
|
|
||||||
|
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 ? { range: [0., maxY * 1.1] } : {}
|
||||||
|
},
|
||||||
|
cursor: { show: false },
|
||||||
|
legend: { show: false, live: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log(opts)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
$: onSizeChange(width, height)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
plotWrapper.style.backgroundColor = backgroundColor()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (uplot)
|
||||||
|
uplot.destroy()
|
||||||
|
|
||||||
|
if (timeoutId != null)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// `from` and `to` must be numbers between 0 and 1.
|
||||||
|
export function setTimeRange(from, to) {
|
||||||
|
if (!uplot || from > to)
|
||||||
|
return false
|
||||||
|
|
||||||
|
uplot.setScale('x', { min: from * maxX, max: to * maxX })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script context="module">
|
||||||
|
|
||||||
|
export function formatTime(t) {
|
||||||
|
let h = Math.floor(t / 3600)
|
||||||
|
let m = Math.floor((t % 3600) / 60)
|
||||||
|
if (h == 0)
|
||||||
|
return `${m}m`
|
||||||
|
else if (m == 0)
|
||||||
|
return `${h}h`
|
||||||
|
else
|
||||||
|
return `${h}:${m}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeIncrs(timestep, maxX) {
|
||||||
|
let incrs = []
|
||||||
|
for (let t = timestep; t < maxX; t *= 10)
|
||||||
|
incrs.push(t, t * 2, t * 3, t * 5)
|
||||||
|
|
||||||
|
return incrs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findThresholds(metricConfig, scope, subCluster) {
|
||||||
|
if (!metricConfig || !scope || !subCluster)
|
||||||
|
return null
|
||||||
|
|
||||||
|
if (scope == 'node' || metricConfig.aggregation == 'avg') {
|
||||||
|
if (!metricConfig.subClusters)
|
||||||
|
return { normal: metricConfig.normal, caution: metricConfig.caution, alert: metricConfig.alert }
|
||||||
|
else
|
||||||
|
return metricConfig.subClusters.find(sc => sc.name == subCluster.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricConfig.aggregation != 'sum') {
|
||||||
|
console.warn('Missing or unkown aggregation mode (sum/avg) for metric:', metricConfig)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let divisor = 1
|
||||||
|
if (scope == 'socket')
|
||||||
|
divisor = subCluster.topology.socket.length
|
||||||
|
else if (scope == 'core')
|
||||||
|
divisor = subCluster.topology.core.length
|
||||||
|
else if (scope == 'accelerator')
|
||||||
|
divisor = subCluster.topology.accelerators.length
|
||||||
|
else if (scope == 'hwthread')
|
||||||
|
divisor = subCluster.topology.node.length
|
||||||
|
else {
|
||||||
|
console.log('TODO: how to calc thresholds for ', scope)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let mc = metricConfig?.subClusters?.find(sc => sc.name == subCluster.name) || metricConfig
|
||||||
|
return {
|
||||||
|
normal: mc.normal / divisor,
|
||||||
|
caution: mc.caution / divisor,
|
||||||
|
alert: mc.alert / divisor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={plotWrapper} class="cc-plot"></div>
|
||||||
|
<style>
|
||||||
|
.cc-plot {
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
190
web/frontend/src/plots/Polar.svelte
Normal file
190
web/frontend/src/plots/Polar.svelte
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
<div>
|
||||||
|
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onMount, getContext } from 'svelte'
|
||||||
|
|
||||||
|
export let metrics
|
||||||
|
export let width
|
||||||
|
export let height
|
||||||
|
export let cluster
|
||||||
|
export let jobMetrics
|
||||||
|
|
||||||
|
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 metricConfig = getContext('metrics')
|
||||||
|
|
||||||
|
let ctx, canvasElement
|
||||||
|
|
||||||
|
const labels = metrics.filter(name => {
|
||||||
|
if (!jobMetrics.find(m => m.name == name && m.metric.scope == "node")) {
|
||||||
|
console.warn(`PolarPlot: No metric data for '${name}'`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const getValuesForStat = (getStat) => labels.map(name => {
|
||||||
|
const peak = metricConfig(cluster, name).peak
|
||||||
|
const metric = jobMetrics.find(m => m.name == name && m.metric.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 = [
|
||||||
|
{
|
||||||
|
name: 'Max',
|
||||||
|
values: getValuesForStat(getMax),
|
||||||
|
color: 'rgb(0, 102, 255)',
|
||||||
|
areaColor: 'rgba(0, 102, 255, 0.25)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Avg',
|
||||||
|
values: getValuesForStat(getAvg),
|
||||||
|
color: 'rgb(255, 153, 0)',
|
||||||
|
areaColor: 'rgba(255, 153, 0, 0.25)'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!width || Number.isNaN(width))
|
||||||
|
return
|
||||||
|
|
||||||
|
const centerX = width / 2
|
||||||
|
const centerY = height / 2 - 15
|
||||||
|
const radius = (Math.min(width, height) / 2) - 50
|
||||||
|
|
||||||
|
// Draw circles
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.strokeStyle = '#999999'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(centerX, centerY, radius * 1.0, 0, Math.PI * 2, false)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(centerX, centerY, radius * 0.666, 0, Math.PI * 2, false)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(centerX, centerY, radius * 0.333, 0, Math.PI * 2, false)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Axis
|
||||||
|
ctx.font = `${fontSize}px ${fontFamily}`
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText('1/3',
|
||||||
|
Math.floor(centerX + radius * 0.333),
|
||||||
|
Math.floor(centerY + 15))
|
||||||
|
ctx.fillText('2/3',
|
||||||
|
Math.floor(centerX + radius * 0.666),
|
||||||
|
Math.floor(centerY + 15))
|
||||||
|
ctx.fillText('1.0',
|
||||||
|
Math.floor(centerX + radius * 1.0),
|
||||||
|
Math.floor(centerY + 15))
|
||||||
|
|
||||||
|
// Label text and straight lines from center
|
||||||
|
for (let i = 0; i < labels.length; i++) {
|
||||||
|
const angle = 2 * Math.PI * ((i + 1) / labels.length)
|
||||||
|
const dx = Math.cos(angle) * radius
|
||||||
|
const dy = Math.sin(angle) * radius
|
||||||
|
ctx.fillText(labels[i],
|
||||||
|
Math.floor(centerX + dx * 1.1),
|
||||||
|
Math.floor(centerY + dy * 1.1))
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(centerX, centerY)
|
||||||
|
ctx.lineTo(centerX + dx, centerY + dy)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let dataset of data) {
|
||||||
|
console.assert(dataset.values.length === labels.length, 'this will look confusing')
|
||||||
|
ctx.fillStyle = dataset.color
|
||||||
|
ctx.strokeStyle = dataset.color
|
||||||
|
const points = []
|
||||||
|
for (let i = 0; i < dataset.values.length; i++) {
|
||||||
|
const value = dataset.values[i]
|
||||||
|
const angle = 2 * Math.PI * ((i + 1) / labels.length)
|
||||||
|
const x = centerX + Math.cos(angle) * radius * value
|
||||||
|
const y = centerY + Math.sin(angle) * radius * value
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, y, 3, 0, Math.PI * 2, false)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
points.push({ x, y })
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Fill" the shape this dataset has
|
||||||
|
ctx.fillStyle = dataset.areaColor
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(points[0].x, points[0].y)
|
||||||
|
for (let p of points)
|
||||||
|
ctx.lineTo(p.x, p.y)
|
||||||
|
ctx.lineTo(points[0].x, points[0].y)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend at the bottom left corner
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
let paddingLeft = 0
|
||||||
|
for (let dataset of data) {
|
||||||
|
const text = `${dataset.name}: `
|
||||||
|
const textWidth = ctx.measureText(text).width
|
||||||
|
ctx.fillStyle = 'black'
|
||||||
|
ctx.fillText(text, paddingLeft, height - 20)
|
||||||
|
|
||||||
|
ctx.fillStyle = dataset.color
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(paddingLeft + textWidth + 5, height - 25, 5, 0, Math.PI * 2, false)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
paddingLeft += textWidth + 15
|
||||||
|
}
|
||||||
|
ctx.fillStyle = 'black'
|
||||||
|
ctx.fillText(`Values relative to respective peak.`, 0, height - 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = false
|
||||||
|
onMount(() => {
|
||||||
|
canvasElement.width = width
|
||||||
|
canvasElement.height = height
|
||||||
|
ctx = canvasElement.getContext('2d')
|
||||||
|
render(ctx, data, width, height)
|
||||||
|
mounted = true
|
||||||
|
})
|
||||||
|
|
||||||
|
let timeoutId = null
|
||||||
|
function sizeChanged() {
|
||||||
|
if (!mounted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (timeoutId != null)
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
timeoutId = null
|
||||||
|
|
||||||
|
canvasElement.width = width
|
||||||
|
canvasElement.height = height
|
||||||
|
ctx = canvasElement.getContext('2d')
|
||||||
|
render(ctx, data, width, height)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
$: sizeChanged(width, height)
|
||||||
|
</script>
|
355
web/frontend/src/plots/Roofline.svelte
Normal file
355
web/frontend/src/plots/Roofline.svelte
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
<div class="cc-plot">
|
||||||
|
<canvas bind:this={canvasElement} width="{prevWidth}" height="{prevHeight}"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script context="module">
|
||||||
|
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 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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const power = [1, 1e3, 1e6, 1e9, 1e12]
|
||||||
|
const suffix = ['', 'k', 'm', 'g']
|
||||||
|
function formatNumber(x) {
|
||||||
|
for (let i = 0; i < suffix.length; i++)
|
||||||
|
if (power[i] <= x && x < power[i+1])
|
||||||
|
return `${x / power[i]}${suffix[i]}`
|
||||||
|
|
||||||
|
return Math.abs(x) >= 1000 ? x.toExponential() : x.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
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, cluster, width, height, colorDots, defaultMaxY) {
|
||||||
|
if (width <= 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
const [minX, maxX, minY, maxY] = [0.01, 1000, 1., cluster?.flopRateSimd || 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 = `${fontSize}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 + fontSize + 5)
|
||||||
|
ctx.moveTo(px, paddingTop - 5)
|
||||||
|
ctx.lineTo(px, height - paddingBottom + 5)
|
||||||
|
|
||||||
|
x *= axisStepFactor(i, w)
|
||||||
|
}
|
||||||
|
if (data.xLabel) {
|
||||||
|
let textWidth = ctx.measureText(data.xLabel).width
|
||||||
|
ctx.fillText(data.xLabel, Math.floor((width / 2) - (textWidth / 2)), height - 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
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.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.x && data.y) {
|
||||||
|
for (let i = 0; i < data.x.length; i++) {
|
||||||
|
let x = data.x[i], y = data.y[i], c = data.c[i]
|
||||||
|
if (x == null || y == null || Number.isNaN(x) || Number.isNaN(y))
|
||||||
|
continue
|
||||||
|
|
||||||
|
const s = 3
|
||||||
|
const px = getCanvasX(x)
|
||||||
|
const py = getCanvasY(y)
|
||||||
|
|
||||||
|
ctx.fillStyle = getRGB(c)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(px, py, s, 0, Math.PI * 2, false)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
} else 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 (cluster != null) {
|
||||||
|
const ycut = 0.01 * cluster.memoryBandwidth
|
||||||
|
const scalarKnee = (cluster.flopRateScalar - ycut) / cluster.memoryBandwidth
|
||||||
|
const simdKnee = (cluster.flopRateSimd - ycut) / cluster.memoryBandwidth
|
||||||
|
const scalarKneeX = getCanvasX(scalarKnee),
|
||||||
|
simdKneeX = getCanvasX(simdKnee),
|
||||||
|
flopRateScalarY = getCanvasY(cluster.flopRateScalar),
|
||||||
|
flopRateSimdY = getCanvasY(cluster.flopRateSimd)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if (colorDots && data.x && data.y) {
|
||||||
|
// The Color Scale
|
||||||
|
ctx.fillStyle = 'black'
|
||||||
|
ctx.fillText('Time:', 17, height - 5)
|
||||||
|
const start = paddingLeft + 5
|
||||||
|
for (let x = start; x < width - paddingRight; x += 15) {
|
||||||
|
let c = (x - start) / (width - start - paddingRight)
|
||||||
|
ctx.fillStyle = getRGB(c)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(x, height - 10, 5, 0, Math.PI * 2, false)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformData(flopsAny, memBw, colorDots) {
|
||||||
|
const nodes = flopsAny.series.length
|
||||||
|
const timesteps = flopsAny.series[0].data.length
|
||||||
|
|
||||||
|
/* c will contain values from 0 to 1 representing the time */
|
||||||
|
const x = [], y = [], c = []
|
||||||
|
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(colorDots ? j / timesteps : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x, y, c,
|
||||||
|
xLabel: 'Intensity [FLOPS/byte]',
|
||||||
|
yLabel: 'Performance [GFLOPS]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return something to be plotted. The argument shall be the result of the
|
||||||
|
// `nodeMetrics` GraphQL query.
|
||||||
|
export function transformPerNodeData(nodes) {
|
||||||
|
const x = [], y = [], c = []
|
||||||
|
for (let node of nodes) {
|
||||||
|
let flopsAny = node.metrics.find(m => m.name == 'flops_any' && m.metric.scope == 'node')?.metric
|
||||||
|
let memBw = node.metrics.find(m => m.name == 'mem_bw' && m.metric.scope == 'node')?.metric
|
||||||
|
if (!flopsAny || !memBw)
|
||||||
|
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)
|
||||||
|
c.push(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x, y, c,
|
||||||
|
xLabel: 'Intensity [FLOPS/byte]',
|
||||||
|
yLabel: 'Performance [GFLOPS]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onMount, tick } from 'svelte'
|
||||||
|
|
||||||
|
export let flopsAny = null
|
||||||
|
export let memBw = null
|
||||||
|
export let cluster = null
|
||||||
|
export let maxY = null
|
||||||
|
export let width
|
||||||
|
export let height
|
||||||
|
export let tiles = null
|
||||||
|
export let colorDots = true
|
||||||
|
export let data = null
|
||||||
|
|
||||||
|
console.assert(data || tiles || (flopsAny && memBw), "you must provide flopsAny and memBw or tiles!")
|
||||||
|
|
||||||
|
let ctx, canvasElement, prevWidth = width, prevHeight = height
|
||||||
|
data = data != null ? data : (flopsAny && memBw
|
||||||
|
? transformData(flopsAny, memBw, colorDots)
|
||||||
|
: {
|
||||||
|
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, cluster, width, height, colorDots, 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, cluster, width, height, colorDots, maxY)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
$: sizeChanged(width, height)
|
||||||
|
</script>
|
171
web/frontend/src/plots/Scatter.svelte
Normal file
171
web/frontend/src/plots/Scatter.svelte
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<div class="cc-plot">
|
||||||
|
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script context="module">
|
||||||
|
import { formatNumber } from '../utils.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>
|
12
web/frontend/src/status.entrypoint.js
Normal file
12
web/frontend/src/status.entrypoint.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import {} from './header.entrypoint.js'
|
||||||
|
import Status from './Status.root.svelte'
|
||||||
|
|
||||||
|
new Status({
|
||||||
|
target: document.getElementById('svelte-app'),
|
||||||
|
props: {
|
||||||
|
cluster: infos.cluster,
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
['cc-config', clusterCockpitConfig]
|
||||||
|
])
|
||||||
|
})
|
14
web/frontend/src/systems.entrypoint.js
Normal file
14
web/frontend/src/systems.entrypoint.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import {} from './header.entrypoint.js'
|
||||||
|
import Systems from './Systems.root.svelte'
|
||||||
|
|
||||||
|
new Systems({
|
||||||
|
target: document.getElementById('svelte-app'),
|
||||||
|
props: {
|
||||||
|
cluster: infos.cluster,
|
||||||
|
from: infos.from,
|
||||||
|
to: infos.to
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
['cc-config', clusterCockpitConfig]
|
||||||
|
])
|
||||||
|
})
|
13
web/frontend/src/user.entrypoint.js
Normal file
13
web/frontend/src/user.entrypoint.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import {} from './header.entrypoint.js'
|
||||||
|
import User from './User.root.svelte'
|
||||||
|
|
||||||
|
new User({
|
||||||
|
target: document.getElementById('svelte-app'),
|
||||||
|
props: {
|
||||||
|
filterPresets: filterPresets,
|
||||||
|
user: userInfos
|
||||||
|
},
|
||||||
|
context: new Map([
|
||||||
|
['cc-config', clusterCockpitConfig]
|
||||||
|
])
|
||||||
|
})
|
288
web/frontend/src/utils.js
Normal file
288
web/frontend/src/utils.js
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
import { expiringCacheExchange } from './cache-exchange.js'
|
||||||
|
import { initClient } from '@urql/svelte'
|
||||||
|
import { setContext, getContext, hasContext, onDestroy, tick } from 'svelte'
|
||||||
|
import { dedupExchange, fetchExchange } from '@urql/core'
|
||||||
|
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 'metrics' to the context, a function that takes a cluster and metric name and returns the MetricConfig (or undefined)
|
||||||
|
*/
|
||||||
|
export function init(extraInitQuery = '') {
|
||||||
|
const jwt = hasContext('jwt')
|
||||||
|
? getContext('jwt')
|
||||||
|
: getContext('cc-config')['jwt']
|
||||||
|
|
||||||
|
const client = initClient({
|
||||||
|
url: `${window.location.origin}/query`,
|
||||||
|
fetchOptions: jwt != null
|
||||||
|
? { headers: { 'Authorization': `Bearer ${jwt}` } } : {},
|
||||||
|
exchanges: [
|
||||||
|
dedupExchange,
|
||||||
|
expiringCacheExchange({
|
||||||
|
ttl: 5 * 60 * 1000,
|
||||||
|
maxSize: 150,
|
||||||
|
}),
|
||||||
|
fetchExchange
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const query = client.query(`query {
|
||||||
|
clusters {
|
||||||
|
name,
|
||||||
|
metricConfig {
|
||||||
|
name, unit, peak,
|
||||||
|
normal, caution, alert,
|
||||||
|
timestep, scope,
|
||||||
|
aggregation,
|
||||||
|
subClusters { name, peak, normal, caution, alert }
|
||||||
|
}
|
||||||
|
filterRanges {
|
||||||
|
duration { from, to }
|
||||||
|
numNodes { from, to }
|
||||||
|
startTime { from, to }
|
||||||
|
}
|
||||||
|
partitions
|
||||||
|
subClusters {
|
||||||
|
name, processorType
|
||||||
|
socketsPerNode
|
||||||
|
coresPerSocket
|
||||||
|
threadsPerCore
|
||||||
|
flopRateScalar
|
||||||
|
flopRateSimd
|
||||||
|
memoryBandwidth
|
||||||
|
numberOfNodes
|
||||||
|
topology {
|
||||||
|
node, socket, core
|
||||||
|
accelerators { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags { id, name, type }
|
||||||
|
${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 = [], clusters = []
|
||||||
|
setContext('tags', tags)
|
||||||
|
setContext('clusters', clusters)
|
||||||
|
setContext('metrics', (cluster, metric) => {
|
||||||
|
if (typeof cluster !== 'object')
|
||||||
|
cluster = clusters.find(c => c.name == cluster)
|
||||||
|
|
||||||
|
return cluster.metricConfig.find(m => m.name == metric)
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
|
||||||
|
state.data = data
|
||||||
|
tick().then(() => subscribers.forEach(cb => cb(state)))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: { subscribe },
|
||||||
|
tags,
|
||||||
|
clusters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(x) {
|
||||||
|
let suffix = ''
|
||||||
|
if (x >= 1000000000) {
|
||||||
|
x /= 1000000
|
||||||
|
suffix = 'G'
|
||||||
|
} else if (x >= 1000000) {
|
||||||
|
x /= 1000000
|
||||||
|
suffix = 'M'
|
||||||
|
} else if (x >= 1000) {
|
||||||
|
x /= 1000
|
||||||
|
suffix = 'k'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(Math.round(x * 100) / 100)}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
"accelerator": 5,
|
||||||
|
"core": 2,
|
||||||
|
"hwthread": 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 async function fetchMetrics(job, metrics, scopes) {
|
||||||
|
if (job.monitoringStatus == 0)
|
||||||
|
return null
|
||||||
|
|
||||||
|
let query = []
|
||||||
|
if (metrics != null) {
|
||||||
|
for (let metric of metrics) {
|
||||||
|
query.push(`metric=${metric}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scopes != null) {
|
||||||
|
for (let scope of scopes) {
|
||||||
|
query.push(`scope=${scope}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res = await fetch(`/api/jobs/metrics/${job.id}${(query.length > 0) ? '?' : ''}${query.join('&')}`)
|
||||||
|
if (res.status != 200) {
|
||||||
|
return { error: { status: res.status, message: await res.text() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMetricsStore() {
|
||||||
|
let set = null
|
||||||
|
return [
|
||||||
|
readable({ fetching: true, error: null, data: null }, (_set) => { set = _set }),
|
||||||
|
(job, metrics, scopes) => fetchMetrics(job, metrics, scopes).then(res => set({
|
||||||
|
fetching: false,
|
||||||
|
error: res.error,
|
||||||
|
data: res.data
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
493
web/frontend/yarn.lock
Normal file
493
web/frontend/yarn.lock
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||||
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@babel/code-frame@^7.10.4":
|
||||||
|
version "7.16.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.0.tgz#0dfc80309beec8411e65e706461c408b0bb9b431"
|
||||||
|
integrity sha512-IF4EOMEV+bfYwOmNxGzSnjR2EmQod7f1UXOpZM3l4i4o4QNwzjtJAu/HxdjHq0aYBvdqMuQEY1eg0nqW9ZPORA==
|
||||||
|
dependencies:
|
||||||
|
"@babel/highlight" "^7.16.0"
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier@^7.15.7":
|
||||||
|
version "7.15.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz#220df993bfe904a4a6b02ab4f3385a5ebf6e2389"
|
||||||
|
integrity sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==
|
||||||
|
|
||||||
|
"@babel/highlight@^7.16.0":
|
||||||
|
version "7.16.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.0.tgz#6ceb32b2ca4b8f5f361fb7fd821e3fddf4a1725a"
|
||||||
|
integrity sha512-t8MH41kUQylBtu2+4IQA3atqevA2lRgqA2wyVB/YiWmsDSuylZZuXOUy9ric30hfzauEFfdsuk/eXTRrGrfd0g==
|
||||||
|
dependencies:
|
||||||
|
"@babel/helper-validator-identifier" "^7.15.7"
|
||||||
|
chalk "^2.0.0"
|
||||||
|
js-tokens "^4.0.0"
|
||||||
|
|
||||||
|
"@graphql-typed-document-node/core@^3.1.0":
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.1.tgz#076d78ce99822258cf813ecc1e7fa460fa74d052"
|
||||||
|
integrity sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg==
|
||||||
|
|
||||||
|
"@popperjs/core@^2.9.2":
|
||||||
|
version "2.11.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.0.tgz#6734f8ebc106a0860dff7f92bf90df193f0935d7"
|
||||||
|
integrity sha512-zrsUxjLOKAzdewIDRWy9nsV1GQsKBCWaGwsZQlCgr6/q+vjyZhFgqedLfFBuI9anTPEUT4APq9Mu0SZBTzIcGQ==
|
||||||
|
|
||||||
|
"@rollup/plugin-commonjs@^17.0.0":
|
||||||
|
version "17.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-17.1.0.tgz#757ec88737dffa8aa913eb392fade2e45aef2a2d"
|
||||||
|
integrity sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew==
|
||||||
|
dependencies:
|
||||||
|
"@rollup/pluginutils" "^3.1.0"
|
||||||
|
commondir "^1.0.1"
|
||||||
|
estree-walker "^2.0.1"
|
||||||
|
glob "^7.1.6"
|
||||||
|
is-reference "^1.2.1"
|
||||||
|
magic-string "^0.25.7"
|
||||||
|
resolve "^1.17.0"
|
||||||
|
|
||||||
|
"@rollup/plugin-node-resolve@^11.0.0":
|
||||||
|
version "11.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz#82aa59397a29cd4e13248b106e6a4a1880362a60"
|
||||||
|
integrity sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==
|
||||||
|
dependencies:
|
||||||
|
"@rollup/pluginutils" "^3.1.0"
|
||||||
|
"@types/resolve" "1.17.1"
|
||||||
|
builtin-modules "^3.1.0"
|
||||||
|
deepmerge "^4.2.2"
|
||||||
|
is-module "^1.0.0"
|
||||||
|
resolve "^1.19.0"
|
||||||
|
|
||||||
|
"@rollup/plugin-replace@^2.4.1":
|
||||||
|
version "2.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz#a2d539314fbc77c244858faa523012825068510a"
|
||||||
|
integrity sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==
|
||||||
|
dependencies:
|
||||||
|
"@rollup/pluginutils" "^3.1.0"
|
||||||
|
magic-string "^0.25.7"
|
||||||
|
|
||||||
|
"@rollup/pluginutils@4":
|
||||||
|
version "4.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.1.1.tgz#1d4da86dd4eded15656a57d933fda2b9a08d47ec"
|
||||||
|
integrity sha512-clDjivHqWGXi7u+0d2r2sBi4Ie6VLEAzWMIkvJLnDmxoOhBYOTfzGbOQBA32THHm11/LiJbd01tJUpJsbshSWQ==
|
||||||
|
dependencies:
|
||||||
|
estree-walker "^2.0.1"
|
||||||
|
picomatch "^2.2.2"
|
||||||
|
|
||||||
|
"@rollup/pluginutils@^3.1.0":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
|
||||||
|
integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
|
||||||
|
dependencies:
|
||||||
|
"@types/estree" "0.0.39"
|
||||||
|
estree-walker "^1.0.1"
|
||||||
|
picomatch "^2.2.2"
|
||||||
|
|
||||||
|
"@types/estree@*":
|
||||||
|
version "0.0.50"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
|
||||||
|
integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
|
||||||
|
|
||||||
|
"@types/estree@0.0.39":
|
||||||
|
version "0.0.39"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
|
||||||
|
integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
|
||||||
|
|
||||||
|
"@types/node@*":
|
||||||
|
version "16.11.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.12.tgz#ac7fb693ac587ee182c3780c26eb65546a1a3c10"
|
||||||
|
integrity sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==
|
||||||
|
|
||||||
|
"@types/resolve@1.17.1":
|
||||||
|
version "1.17.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
|
||||||
|
integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@urql/core@^2.3.4":
|
||||||
|
version "2.3.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@urql/core/-/core-2.3.5.tgz#eb1cbbfe23236615ecb8e65850bb772d4f61b6b5"
|
||||||
|
integrity sha512-kM/um4OjXmuN6NUS/FSm7dESEKWT7By1kCRCmjvU4+4uEoF1cd4TzIhQ7J1I3zbDAFhZzmThq9X0AHpbHAn3bA==
|
||||||
|
dependencies:
|
||||||
|
"@graphql-typed-document-node/core" "^3.1.0"
|
||||||
|
wonka "^4.0.14"
|
||||||
|
|
||||||
|
"@urql/svelte@^1.3.0":
|
||||||
|
version "1.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@urql/svelte/-/svelte-1.3.2.tgz#7fc16253a36669dddec39755fc9c31077a9c279a"
|
||||||
|
integrity sha512-L/fSKb+jTrxfeKbnA4+7T69sL0XlzMv4d9i0j9J+fCkBCpUOGgPsYzsyBttbVbjrlaw61Wrc6J2NKuokrd570w==
|
||||||
|
dependencies:
|
||||||
|
"@urql/core" "^2.3.4"
|
||||||
|
wonka "^4.0.14"
|
||||||
|
|
||||||
|
ansi-styles@^3.2.1:
|
||||||
|
version "3.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
|
||||||
|
integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
|
||||||
|
dependencies:
|
||||||
|
color-convert "^1.9.0"
|
||||||
|
|
||||||
|
balanced-match@^1.0.0:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||||
|
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||||
|
|
||||||
|
brace-expansion@^1.1.7:
|
||||||
|
version "1.1.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||||
|
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
|
||||||
|
dependencies:
|
||||||
|
balanced-match "^1.0.0"
|
||||||
|
concat-map "0.0.1"
|
||||||
|
|
||||||
|
buffer-from@^1.0.0:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
|
||||||
|
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
|
||||||
|
|
||||||
|
builtin-modules@^3.1.0:
|
||||||
|
version "3.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887"
|
||||||
|
integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==
|
||||||
|
|
||||||
|
chalk@^2.0.0:
|
||||||
|
version "2.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
|
||||||
|
integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
|
||||||
|
dependencies:
|
||||||
|
ansi-styles "^3.2.1"
|
||||||
|
escape-string-regexp "^1.0.5"
|
||||||
|
supports-color "^5.3.0"
|
||||||
|
|
||||||
|
color-convert@^1.9.0:
|
||||||
|
version "1.9.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
||||||
|
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
|
||||||
|
dependencies:
|
||||||
|
color-name "1.1.3"
|
||||||
|
|
||||||
|
color-name@1.1.3:
|
||||||
|
version "1.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
|
||||||
|
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
|
||||||
|
|
||||||
|
commander@^2.20.0:
|
||||||
|
version "2.20.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||||
|
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||||
|
|
||||||
|
commondir@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
|
||||||
|
integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
|
||||||
|
|
||||||
|
concat-map@0.0.1:
|
||||||
|
version "0.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||||
|
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
|
||||||
|
|
||||||
|
deepmerge@^4.2.2:
|
||||||
|
version "4.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
|
||||||
|
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
|
||||||
|
|
||||||
|
escape-string-regexp@^1.0.5:
|
||||||
|
version "1.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
||||||
|
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
|
||||||
|
|
||||||
|
estree-walker@^0.6.1:
|
||||||
|
version "0.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362"
|
||||||
|
integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==
|
||||||
|
|
||||||
|
estree-walker@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
|
||||||
|
integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
|
||||||
|
|
||||||
|
estree-walker@^2.0.1:
|
||||||
|
version "2.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
|
||||||
|
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
|
||||||
|
|
||||||
|
fs.realpath@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||||
|
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
|
||||||
|
|
||||||
|
fsevents@~2.3.2:
|
||||||
|
version "2.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||||
|
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||||
|
|
||||||
|
function-bind@^1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
|
||||||
|
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
|
||||||
|
|
||||||
|
glob@^7.1.6:
|
||||||
|
version "7.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
|
||||||
|
integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
|
||||||
|
dependencies:
|
||||||
|
fs.realpath "^1.0.0"
|
||||||
|
inflight "^1.0.4"
|
||||||
|
inherits "2"
|
||||||
|
minimatch "^3.0.4"
|
||||||
|
once "^1.3.0"
|
||||||
|
path-is-absolute "^1.0.0"
|
||||||
|
|
||||||
|
graphql@^15.6.0:
|
||||||
|
version "15.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.8.0.tgz#33410e96b012fa3bdb1091cc99a94769db212b38"
|
||||||
|
integrity sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==
|
||||||
|
|
||||||
|
has-flag@^3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
|
||||||
|
integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
|
||||||
|
|
||||||
|
has-flag@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
||||||
|
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
||||||
|
|
||||||
|
has@^1.0.3:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
|
||||||
|
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
|
||||||
|
dependencies:
|
||||||
|
function-bind "^1.1.1"
|
||||||
|
|
||||||
|
inflight@^1.0.4:
|
||||||
|
version "1.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
|
||||||
|
integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
|
||||||
|
dependencies:
|
||||||
|
once "^1.3.0"
|
||||||
|
wrappy "1"
|
||||||
|
|
||||||
|
inherits@2:
|
||||||
|
version "2.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
is-core-module@^2.2.0:
|
||||||
|
version "2.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.0.tgz#0321336c3d0925e497fd97f5d95cb114a5ccd548"
|
||||||
|
integrity sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==
|
||||||
|
dependencies:
|
||||||
|
has "^1.0.3"
|
||||||
|
|
||||||
|
is-module@^1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
|
||||||
|
integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=
|
||||||
|
|
||||||
|
is-reference@^1.2.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7"
|
||||||
|
integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/estree" "*"
|
||||||
|
|
||||||
|
jest-worker@^26.2.1:
|
||||||
|
version "26.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed"
|
||||||
|
integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
merge-stream "^2.0.0"
|
||||||
|
supports-color "^7.0.0"
|
||||||
|
|
||||||
|
js-tokens@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||||
|
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||||
|
|
||||||
|
magic-string@^0.25.7:
|
||||||
|
version "0.25.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051"
|
||||||
|
integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==
|
||||||
|
dependencies:
|
||||||
|
sourcemap-codec "^1.4.4"
|
||||||
|
|
||||||
|
merge-stream@^2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
|
||||||
|
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
|
||||||
|
|
||||||
|
minimatch@^3.0.4:
|
||||||
|
version "3.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
|
||||||
|
integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
|
||||||
|
dependencies:
|
||||||
|
brace-expansion "^1.1.7"
|
||||||
|
|
||||||
|
once@^1.3.0:
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
|
||||||
|
integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
|
||||||
|
dependencies:
|
||||||
|
wrappy "1"
|
||||||
|
|
||||||
|
path-is-absolute@^1.0.0:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
|
||||||
|
integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
|
||||||
|
|
||||||
|
path-parse@^1.0.6:
|
||||||
|
version "1.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||||
|
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||||
|
|
||||||
|
picomatch@^2.2.2:
|
||||||
|
version "2.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
|
||||||
|
integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
|
||||||
|
|
||||||
|
randombytes@^2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
|
||||||
|
integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
|
||||||
|
dependencies:
|
||||||
|
safe-buffer "^5.1.0"
|
||||||
|
|
||||||
|
require-relative@^0.8.7:
|
||||||
|
version "0.8.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/require-relative/-/require-relative-0.8.7.tgz#7999539fc9e047a37928fa196f8e1563dabd36de"
|
||||||
|
integrity sha1-eZlTn8ngR6N5KPoZb44VY9q9Nt4=
|
||||||
|
|
||||||
|
resolve@^1.17.0, resolve@^1.19.0:
|
||||||
|
version "1.20.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
|
||||||
|
integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
|
||||||
|
dependencies:
|
||||||
|
is-core-module "^2.2.0"
|
||||||
|
path-parse "^1.0.6"
|
||||||
|
|
||||||
|
rollup-plugin-css-only@^3.1.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/rollup-plugin-css-only/-/rollup-plugin-css-only-3.1.0.tgz#6a701cc5b051c6b3f0961e69b108a9a118e1b1df"
|
||||||
|
integrity sha512-TYMOE5uoD76vpj+RTkQLzC9cQtbnJNktHPB507FzRWBVaofg7KhIqq1kGbcVOadARSozWF883Ho9KpSPKH8gqA==
|
||||||
|
dependencies:
|
||||||
|
"@rollup/pluginutils" "4"
|
||||||
|
|
||||||
|
rollup-plugin-svelte@^7.0.0:
|
||||||
|
version "7.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/rollup-plugin-svelte/-/rollup-plugin-svelte-7.1.0.tgz#d45f2b92b1014be4eb46b55aa033fb9a9c65f04d"
|
||||||
|
integrity sha512-vopCUq3G+25sKjwF5VilIbiY6KCuMNHP1PFvx2Vr3REBNMDllKHFZN2B9jwwC+MqNc3UPKkjXnceLPEjTjXGXg==
|
||||||
|
dependencies:
|
||||||
|
require-relative "^0.8.7"
|
||||||
|
rollup-pluginutils "^2.8.2"
|
||||||
|
|
||||||
|
rollup-plugin-terser@^7.0.0:
|
||||||
|
version "7.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d"
|
||||||
|
integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==
|
||||||
|
dependencies:
|
||||||
|
"@babel/code-frame" "^7.10.4"
|
||||||
|
jest-worker "^26.2.1"
|
||||||
|
serialize-javascript "^4.0.0"
|
||||||
|
terser "^5.0.0"
|
||||||
|
|
||||||
|
rollup-pluginutils@^2.8.2:
|
||||||
|
version "2.8.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e"
|
||||||
|
integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==
|
||||||
|
dependencies:
|
||||||
|
estree-walker "^0.6.1"
|
||||||
|
|
||||||
|
rollup@^2.3.4:
|
||||||
|
version "2.61.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.61.0.tgz#ccd927bcd6cc0c78a4689c918627a717977208f4"
|
||||||
|
integrity sha512-teQ+T1mUYbyvGyUavCodiyA9hD4DxwYZJwr/qehZGhs1Z49vsmzelMVYMxGU4ZhGRKxYPupHuz5yzm/wj7VpWA==
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents "~2.3.2"
|
||||||
|
|
||||||
|
safe-buffer@^5.1.0:
|
||||||
|
version "5.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||||
|
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||||
|
|
||||||
|
serialize-javascript@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
|
||||||
|
integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==
|
||||||
|
dependencies:
|
||||||
|
randombytes "^2.1.0"
|
||||||
|
|
||||||
|
source-map-support@~0.5.20:
|
||||||
|
version "0.5.21"
|
||||||
|
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
|
||||||
|
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
|
||||||
|
dependencies:
|
||||||
|
buffer-from "^1.0.0"
|
||||||
|
source-map "^0.6.0"
|
||||||
|
|
||||||
|
source-map@^0.6.0:
|
||||||
|
version "0.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
||||||
|
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||||
|
|
||||||
|
source-map@~0.7.2:
|
||||||
|
version "0.7.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
|
||||||
|
integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
|
||||||
|
|
||||||
|
sourcemap-codec@^1.4.4:
|
||||||
|
version "1.4.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
||||||
|
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
||||||
|
|
||||||
|
supports-color@^5.3.0:
|
||||||
|
version "5.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
|
||||||
|
integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
|
||||||
|
dependencies:
|
||||||
|
has-flag "^3.0.0"
|
||||||
|
|
||||||
|
supports-color@^7.0.0:
|
||||||
|
version "7.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
|
||||||
|
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
|
||||||
|
dependencies:
|
||||||
|
has-flag "^4.0.0"
|
||||||
|
|
||||||
|
svelte@^3.42.6:
|
||||||
|
version "3.44.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.44.2.tgz#3e69be2598308dfc8354ba584cec54e648a50f7f"
|
||||||
|
integrity sha512-jrZhZtmH3ZMweXg1Q15onb8QlWD+a5T5Oca4C1jYvSURp2oD35h4A5TV6t6MEa93K4LlX6BkafZPdQoFjw/ylA==
|
||||||
|
|
||||||
|
sveltestrap@^5.6.1:
|
||||||
|
version "5.6.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/sveltestrap/-/sveltestrap-5.6.3.tgz#afb81b00d0b378719988e5339f92254dce41194f"
|
||||||
|
integrity sha512-/geTKJbPmJGzwHFKYC3NkUNDk/GKxrppgdSxcg58w/qcxs0S6RiN4PaQ1tgBKsdSrZDfbHfkFF+dybHAyUlV0A==
|
||||||
|
dependencies:
|
||||||
|
"@popperjs/core" "^2.9.2"
|
||||||
|
|
||||||
|
terser@^5.0.0:
|
||||||
|
version "5.10.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/terser/-/terser-5.10.0.tgz#b86390809c0389105eb0a0b62397563096ddafcc"
|
||||||
|
integrity sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==
|
||||||
|
dependencies:
|
||||||
|
commander "^2.20.0"
|
||||||
|
source-map "~0.7.2"
|
||||||
|
source-map-support "~0.5.20"
|
||||||
|
|
||||||
|
uplot@^1.6.7:
|
||||||
|
version "1.6.17"
|
||||||
|
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.6.17.tgz#1f8fc07a0e48008798beca463523621ad66dcc46"
|
||||||
|
integrity sha512-WHNHvDCXURn+Qwb3QUUzP6rOxx+3kUZUspREyhkqmXCxFIND99l5z9intTh+uPEt+/EEu7lCaMjSd1uTfuTXfg==
|
||||||
|
|
||||||
|
wonka@^4.0.14, wonka@^4.0.15:
|
||||||
|
version "4.0.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/wonka/-/wonka-4.0.15.tgz#9aa42046efa424565ab8f8f451fcca955bf80b89"
|
||||||
|
integrity sha512-U0IUQHKXXn6PFo9nqsHphVCE5m3IntqZNB9Jjn7EB1lrR7YTDY3YWgFvEvwniTzXSvOH/XMzAZaIfJF/LvHYXg==
|
||||||
|
|
||||||
|
wrappy@1:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||||
|
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
|
Loading…
Reference in New Issue
Block a user