diff --git a/web/frontend/README.md b/web/frontend/README.md new file mode 100644 index 0000000..4d54384 --- /dev/null +++ b/web/frontend/README.md @@ -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. + diff --git a/web/frontend/package.json b/web/frontend/package.json new file mode 100644 index 0000000..2f2ab55 --- /dev/null +++ b/web/frontend/package.json @@ -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" + } +} diff --git a/web/frontend/public/favicon.png b/web/frontend/public/favicon.png new file mode 100644 index 0000000..fa7bf5c Binary files /dev/null and b/web/frontend/public/favicon.png differ diff --git a/web/frontend/public/global.css b/web/frontend/public/global.css new file mode 100644 index 0000000..8feecf6 --- /dev/null +++ b/web/frontend/public/global.css @@ -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; +} diff --git a/web/frontend/public/img/logo.png b/web/frontend/public/img/logo.png new file mode 100644 index 0000000..2ad4fd6 Binary files /dev/null and b/web/frontend/public/img/logo.png differ diff --git a/web/frontend/public/uPlot.min.css b/web/frontend/public/uPlot.min.css new file mode 120000 index 0000000..b11d327 --- /dev/null +++ b/web/frontend/public/uPlot.min.css @@ -0,0 +1 @@ +../node_modules/uplot/dist/uPlot.min.css \ No newline at end of file diff --git a/web/frontend/rollup.config.js b/web/frontend/rollup.config.js new file mode 100644 index 0000000..13d988a --- /dev/null +++ b/web/frontend/rollup.config.js @@ -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') +]; + diff --git a/web/frontend/src/Analysis.root.svelte b/web/frontend/src/Analysis.root.svelte new file mode 100644 index 0000000..a92aea7 --- /dev/null +++ b/web/frontend/src/Analysis.root.svelte @@ -0,0 +1,265 @@ + + + + {#if $initq.fetching || $statsQuery.fetching || $footprintsQuery.fetching} + + + + {/if} + + {#if $initq.error} + {$initq.error.message} + {:else if cluster} + mc.name)} + bind:metricsInHistograms={metricsInHistograms} + bind:metricsInScatterplots={metricsInScatterplots} /> + {/if} + + + { + $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 } + }} /> + + + +
+{#if $statsQuery.error} + + + {$statsQuery.error.message} + + +{:else if $statsQuery.data} + +
+
+ + + + + + + + + + + + + + + + + +
Total Jobs{$statsQuery.data.stats[0].totalJobs}
Short Jobs (< 2m){$statsQuery.data.stats[0].shortJobs}
Total Walltime{$statsQuery.data.stats[0].totalWalltime}
Total Core Hours{$statsQuery.data.stats[0].totalCoreHours}
+
+
+ {#key $statsQuery.data.topUsers} +

Top Users (by node hours)

+ 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} +
+
+
+ {#key $statsQuery.data.stats[0].histDuration} +

Walltime Distribution

+ + {/key} +
+
+ {#key $statsQuery.data.stats[0].histNumNodes} +

Number of Nodes Distribution

+ + {/key} +
+
+ {#if $rooflineQuery.fetching} + + {:else if $rooflineQuery.error} + {$rooflineQuery.error.message} + {:else if $rooflineQuery.data && cluster} + {#key $rooflineQuery.data} + + {/key} + {/if} +
+
+{/if} + +
+{#if $footprintsQuery.error} + + + {$footprintsQuery.error.message} + + +{:else if $footprintsQuery.data && $initq.data} + + + + These histograms show the distribution of the averages of all jobs matching the filters. Each job/average is weighted by its node hours. + +
+ +
+ + + ({ metric, ...binsFromFootprint( + $footprintsQuery.data.footprints.nodehours, + $footprintsQuery.data.footprints.metrics.find(f => f.metric == metric).data, numBins) }))} + itemsPerRow={ccconfig.plot_view_plotsPerRow}> +

{item.metric} [{metricConfig(cluster.name, item.metric)?.unit}]

+ + +
+ +
+
+ + + + 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. + +
+ +
+ + + ({ + 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}> + + + + + +{/if} + + diff --git a/web/frontend/src/Header.svelte b/web/frontend/src/Header.svelte new file mode 100644 index 0000000..f99956a --- /dev/null +++ b/web/frontend/src/Header.svelte @@ -0,0 +1,73 @@ + + + + + ClusterCockpit Logo + + (isOpen = !isOpen)} /> + (isOpen = detail.isOpen)}> + + +
+
+ + + + +
+ {#if username} +
+ +
+ {/if} + +
+
diff --git a/web/frontend/src/Job.root.svelte b/web/frontend/src/Job.root.svelte new file mode 100644 index 0000000..58c0d56 --- /dev/null +++ b/web/frontend/src/Job.root.svelte @@ -0,0 +1,224 @@ + + +
+ + + {#if $initq.error} + {$initq.error.message} + {:else if $initq.data} + + {:else} + + {/if} + + {#if $jobMetrics.data && $initq.data} + + + + + 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} /> + + {:else} + + + {/if} + +
+ + + {#if $initq.data} + + {/if} + + + {#if $initq.data} + + {/if} + + + + + +
+ + + {#if $jobMetrics.error} + {#if $initq.data.job.monitoringStatus == 0 || $initq.data.job.monitoringStatus == 2} + Not monitored or archiving failed +
+ {/if} + {$jobMetrics.error.message} + {:else if $jobMetrics.fetching} + + {:else if $jobMetrics.data && $initq.data} + + {#if item.data} + statsTable.moreLoaded(detail)} + job={$initq.data.job} + metric={item.metric} + scopes={item.data.map(x => x.metric)} + width={width}/> + {:else} + No data for {item.metric} + {/if} + + {/if} + +
+
+ + + {#if $initq.data} + + {#if somethingMissing} + +
+ + Missing Metrics/Reseources + + + {#if missingMetrics.length > 0} +

No data at all is available for the metrics: {missingMetrics.join(', ')}

+ {/if} + {#if missingHosts.length > 0} +

Some metrics are missing for the following hosts:

+
    + {#each missingHosts as missing} +
  • {missing.hostname}: {missing.metrics.join(', ')}
  • + {/each} +
+ {/if} +
+
+
+ {/if} + + {#if $jobMetrics.data} + + {/if} + + +
+ {#if $initq.data.job.metaData?.jobScript} +
{$initq.data.job.metaData?.jobScript}
+ {:else} + No job script available + {/if} +
+
+ +
+ {#if $initq.data.job.metaData?.slurmInfo} +
{$initq.data.job.metaData?.slurmInfo}
+ {:else} + No additional slurm information available + {/if} +
+
+
+ {/if} + +
+ +{#if $initq.data} + +{/if} + + diff --git a/web/frontend/src/Jobs.root.svelte b/web/frontend/src/Jobs.root.svelte new file mode 100644 index 0000000..9ecaafa --- /dev/null +++ b/web/frontend/src/Jobs.root.svelte @@ -0,0 +1,88 @@ + + + + {#if $initq.fetching} + + + + {:else if $initq.error} + + {$initq.error.message} + + {/if} + + + + + + + + + jobList.update(detail.filters)} /> + + + + filters.update(detail)}/> + + + jobList.update()} /> + + +
+ + + + + + + + + diff --git a/web/frontend/src/List.root.svelte b/web/frontend/src/List.root.svelte new file mode 100644 index 0000000..7d973e4 --- /dev/null +++ b/web/frontend/src/List.root.svelte @@ -0,0 +1,151 @@ + + + + + + + + + + + + { + $stats.variables = { filter: detail.filters } + $stats.context.pause = false + $stats.reexecute() + }} /> + + + + + + + + + + + + + {#if $stats.fetching} + + + + {:else if $stats.error} + + + + {:else if $stats.data} + {#each sort($stats.data.rows, sorting, nameFilter) as row (row.id)} + + + + + + + {:else} + + + + {/each} + {/if} + +
+ {({ USER: 'Username', PROJECT: 'Project Name' })[type]} + + + Total Jobs + + + Total Walltime + + + Total Core Hours + +
{$stats.error.message}
+ {#if type == 'USER'} + {scrambleNames ? scramble(row.id) : row.id} + {:else if type == 'PROJECT'} + {row.id} + {:else} + {row.id} + {/if} + {row.totalJobs}{row.totalWalltime}{row.totalCoreHours}
No {type.toLowerCase()}s/jobs found
\ No newline at end of file diff --git a/web/frontend/src/Metric.svelte b/web/frontend/src/Metric.svelte new file mode 100644 index 0000000..f414827 --- /dev/null +++ b/web/frontend/src/Metric.svelte @@ -0,0 +1,88 @@ + + + + {metric} ({metricConfig?.unit}) + + + {#if job.resources.length > 1} + + {/if} + +{#key series} + {#if fetching == true} + + {:else if error != null} + {error.message} + {:else if series != null} + + {/if} +{/key} diff --git a/web/frontend/src/MetricSelection.svelte b/web/frontend/src/MetricSelection.svelte new file mode 100644 index 0000000..4119256 --- /dev/null +++ b/web/frontend/src/MetricSelection.svelte @@ -0,0 +1,126 @@ + + + + + + + (isOpen = !isOpen)}> + + Configure columns + + + + {#each newMetricsOrder as metric, index (metric)} +
  • columnsDragStart(event, index)} + on:drop|preventDefault={event => columnsDrag(event, index)} + on:dragenter={() => columnHovering = index} + class:is-active={columnHovering === index}> + {#if unorderedMetrics.includes(metric)} + + {:else} + + {/if} + {metric} + + {cluster == null ? clusters + .filter(cluster => cluster.metricConfig.find(m => m.name == metric) != null) + .map(cluster => cluster.name).join(', ') : ''} + +
  • + {/each} +
    +
    + + + +
    diff --git a/web/frontend/src/Node.root.svelte b/web/frontend/src/Node.root.svelte new file mode 100644 index 0000000..9534bd7 --- /dev/null +++ b/web/frontend/src/Node.root.svelte @@ -0,0 +1,94 @@ + + + + {#if $initq.error} + {$initq.error.message} + {:else if $initq.fetching} + + {:else} + + + + {hostname} ({cluster}) + + + + + + {/if} + +
    + + + {#if $nodesQuery.error} + {$nodesQuery.error.message} + {:else if $nodesQuery.fetching || $initq.fetching} + + {:else} + a.name.localeCompare(b.name))}> +

    {item.name}

    + c.name == cluster)} subCluster={$nodesQuery.data.nodeMetrics[0].subCluster} + series={item.metric.series} /> +
    + {/if} + +
    diff --git a/web/frontend/src/PlotSelection.svelte b/web/frontend/src/PlotSelection.svelte new file mode 100644 index 0000000..0205c27 --- /dev/null +++ b/web/frontend/src/PlotSelection.svelte @@ -0,0 +1,133 @@ + + + + + + + (isHistogramConfigOpen = !isHistogramConfigOpen)}> + + Select metrics presented in histograms + + + + {#each availableMetrics as metric (metric)} + + updateConfiguration({ + name: 'analysis_view_histogramMetrics', + value: metricsInHistograms + })} /> + + {metric} + + {/each} + + + + + + + + (isScatterPlotConfigOpen = !isScatterPlotConfigOpen)}> + + Select metric pairs presented in scatter plots + + + + {#each metricsInScatterplots as pair} + + {pair[0]} / {pair[1]} + + + + {/each} + + +
    + + + + + + + +
    + + + +
    diff --git a/web/frontend/src/PlotTable.svelte b/web/frontend/src/PlotTable.svelte new file mode 100644 index 0000000..208c4af --- /dev/null +++ b/web/frontend/src/PlotTable.svelte @@ -0,0 +1,50 @@ + + + + + + {#each rows as row} + + {#each row as item (item)} + + {/each} + + {/each} +
    + {#if item != PLACEHOLDER && plotWidth > 0} + + {/if} +
    diff --git a/web/frontend/src/StatsTable.svelte b/web/frontend/src/StatsTable.svelte new file mode 100644 index 0000000..e9400ac --- /dev/null +++ b/web/frontend/src/StatsTable.svelte @@ -0,0 +1,122 @@ + + + + + + + {#each selectedMetrics as metric} + + {/each} + + + + {#each selectedMetrics as metric} + {#if selectedScopes[metric] != 'node'} + + {/if} + {#each ['min', 'avg', 'max'] as stat} + + {/each} + {/each} + + + + {#each hosts as host (host)} + + + {#each selectedMetrics as metric (metric)} + + {/each} + + {/each} + +
    + + + + + {metric} + + + +
    NodeId sortBy(metric, stat)}> + {stat} + {#if selectedScopes[metric] == 'node'} + + {/if} +
    {host}
    + +
    + + diff --git a/web/frontend/src/StatsTableEntry.svelte b/web/frontend/src/StatsTableEntry.svelte new file mode 100644 index 0000000..93cd9f0 --- /dev/null +++ b/web/frontend/src/StatsTableEntry.svelte @@ -0,0 +1,37 @@ + + +{#if series == null || series.length == 0} + No data +{:else if series.length == 1 && scope == 'node'} + + {series[0].statistics.min} + + + {series[0].statistics.avg} + + + {series[0].statistics.max} + +{:else} + + + {#each series as s, i} + + + + + + + {/each} +
    {s.id ?? i}{s.statistics.min}{s.statistics.avg}{s.statistics.max}
    + +{/if} diff --git a/web/frontend/src/Status.root.svelte b/web/frontend/src/Status.root.svelte new file mode 100644 index 0000000..26842c8 --- /dev/null +++ b/web/frontend/src/Status.root.svelte @@ -0,0 +1,184 @@ + + + + + {#if $initq.fetching || $mainQuery.fetching} + + {:else if $initq.error} + {$initq.error.message} + {:else} + + {/if} + + + { + 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' }) + }} /> + + +{#if $mainQuery.error} + + + {$mainQuery.error.message} + + +{/if} +{#if $initq.data && $mainQuery.data} + {#each $initq.data.clusters.find(c => c.name == cluster).subClusters as subCluster, i} + + + + + + + + + + + + + + + + + + + + + + +
    SubCluster{subCluster.name}
    Allocated Nodes
    ({allocatedNodes[subCluster.name]} / {subCluster.numberOfNodes})
    Flop Rate
    ({flopRate[subCluster.name]} / {subCluster.flopRateSimd * subCluster.numberOfNodes})
    MemBw Rate
    ({memBwRate[subCluster.name]} / {subCluster.memoryBandwidth * subCluster.numberOfNodes})
    + +
    + {#key $mainQuery.data.nodeMetrics} + data.subCluster == subCluster.name))} /> + {/key} +
    +
    + {/each} + +
    +

    Top Users

    + {#key $mainQuery.data} + 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} +
    +
    + + + {#each $mainQuery.data.topUsers.sort((a, b) => b.count - a.count) as { name, count }} + + + + + {/each} +
    NameNumber of Nodes
    {name}{count}
    +
    +
    +

    Top Projects

    + {#key $mainQuery.data} + 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} +
    +
    + + + {#each $mainQuery.data.topProjects.sort((a, b) => b.count - a.count) as { name, count }} + + {/each} +
    NameNumber of Nodes
    {name}{count}
    +
    +
    + +
    +

    Duration Distribution

    + {#key $mainQuery.data.stats} + + {/key} +
    +
    +

    Number of Nodes Distribution

    + {#key $mainQuery.data.stats} + + {/key} +
    +
    +{/if} diff --git a/web/frontend/src/Systems.root.svelte b/web/frontend/src/Systems.root.svelte new file mode 100644 index 0000000..fc2db8b --- /dev/null +++ b/web/frontend/src/Systems.root.svelte @@ -0,0 +1,118 @@ + + + + {#if $initq.error} + {$initq.error.message} + {:else if $initq.fetching} + + {:else} + + + + + + + Metric + + + + + + + Find Node + + + + {/if} + +
    + + + {#if $nodesQuery.error} + {$nodesQuery.error.message} + {:else if $nodesQuery.fetching || $initq.fetching} + + {:else} + 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))}> + +

    {item.host} ({item.subCluster})

    + c.name == cluster)} + subCluster={item.subCluster} /> +
    + {/if} + +
    + diff --git a/web/frontend/src/Tag.svelte b/web/frontend/src/Tag.svelte new file mode 100644 index 0000000..76a94ec --- /dev/null +++ b/web/frontend/src/Tag.svelte @@ -0,0 +1,44 @@ + + + + + + + + {#if tag} + {tag.type}: {tag.name} + {:else} + Loading... + {/if} + diff --git a/web/frontend/src/TagManagement.svelte b/web/frontend/src/TagManagement.svelte new file mode 100644 index 0000000..747b092 --- /dev/null +++ b/web/frontend/src/TagManagement.svelte @@ -0,0 +1,173 @@ + + + + + (isOpen = !isOpen)}> + + Manage Tags + {#if pendingChange !== false} + + {:else} + + {/if} + + + + +
    + + + Search using "type: name". If no tag matches your search, + a button for creating a new one will appear. + + +
      + {#each allTagsFiltered as tag} + + + + + {#if pendingChange === tag.id} + + {:else if job.tags.find(t => t.id == tag.id)} + + {:else} + + {/if} + + + {:else} + + No tags matching + + {/each} +
    +
    + {#if newTagType && newTagName && isNewTag(newTagType, newTagName)} + + {:else if allTagsFiltered.length == 0} + Search Term is not a valid Tag (type: name) + {/if} +
    + + + +
    + + diff --git a/web/frontend/src/User.root.svelte b/web/frontend/src/User.root.svelte new file mode 100644 index 0000000..5a8d14d --- /dev/null +++ b/web/frontend/src/User.root.svelte @@ -0,0 +1,172 @@ + + + + {#if $initq.fetching} + + + + {:else if $initq.error} + + {$initq.error.message} + + {/if} + + + + + + + + { + let filters = [...detail.filters, { user: { eq: user.username } }] + $stats.variables = { filter: filters } + $stats.context.pause = false + $stats.reexecute() + jobList.update(filters) + }} /> + + + jobList.update()} /> + + +
    + + {#if $stats.error} + + {$stats.error.message} + + {:else if !$stats.data} + + + + {:else} + + + + + + + + {#if user.name} + + + + + {/if} + {#if user.email} + + + + + {/if} + + + + + + + + + + + + + + + + + +
    Username{scrambleNames ? scramble(user.username) : user.username}
    Name{scrambleNames ? scramble(user.name) : user.name}
    Email{user.email}
    Total Jobs{$stats.data.jobsStatistics[0].totalJobs}
    Short Jobs{$stats.data.jobsStatistics[0].shortJobs}
    Total Walltime{$stats.data.jobsStatistics[0].totalWalltime}
    Total Core Hours{$stats.data.jobsStatistics[0].totalCoreHours}
    + +
    + Walltime + {#key $stats.data.jobsStatistics[0].histDuration} + + {/key} +
    +
    + Number of Nodes + {#key $stats.data.jobsStatistics[0].histNumNodes} + + {/key} +
    + {/if} +
    +
    + + + + + + + + + \ No newline at end of file diff --git a/web/frontend/src/Zoom.svelte b/web/frontend/src/Zoom.svelte new file mode 100644 index 0000000..ae842fc --- /dev/null +++ b/web/frontend/src/Zoom.svelte @@ -0,0 +1,60 @@ + + +
    + + + + + + Window Size: + + + ({windowSize}%) + + + + Window Position: + + + +
    diff --git a/web/frontend/src/analysis.entrypoint.js b/web/frontend/src/analysis.entrypoint.js new file mode 100644 index 0000000..d889144 --- /dev/null +++ b/web/frontend/src/analysis.entrypoint.js @@ -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] + ]) +}) diff --git a/web/frontend/src/cache-exchange.js b/web/frontend/src/cache-exchange.js new file mode 100644 index 0000000..c52843e --- /dev/null +++ b/web/frontend/src/cache-exchange.js @@ -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 + }); + }) + ) + ]); + }; +}; + diff --git a/web/frontend/src/filters/Cluster.svelte b/web/frontend/src/filters/Cluster.svelte new file mode 100644 index 0000000..83c4d91 --- /dev/null +++ b/web/frontend/src/filters/Cluster.svelte @@ -0,0 +1,77 @@ + + + (isOpen = !isOpen)}> + + Select Cluster & Slurm Partition + + + {#if $initialized} +

    Cluster

    + + (pendingCluster = null, pendingPartition = null)}> + Any Cluster + + {#each clusters as cluster} + (pendingCluster = cluster.name, pendingPartition = null)}> + {cluster.name} + + {/each} + + {/if} + {#if $initialized && pendingCluster != null} +
    +

    Partiton

    + + (pendingPartition = null)}> + Any Partition + + {#each clusters.find(c => c.name == pendingCluster).partitions as partition} + (pendingPartition = partition)}> + {partition} + + {/each} + + {/if} +
    + + + + + +
    diff --git a/web/frontend/src/filters/DoubleRangeSlider.svelte b/web/frontend/src/filters/DoubleRangeSlider.svelte new file mode 100644 index 0000000..aca460a --- /dev/null +++ b/web/frontend/src/filters/DoubleRangeSlider.svelte @@ -0,0 +1,302 @@ + + + + +
    +
    + inputChanged(0, e)} /> + + Full Range: {min} - {max} + + inputChanged(1, e)} /> +
    +
    +
    +
    +
    +
    +
    + + diff --git a/web/frontend/src/filters/Duration.svelte b/web/frontend/src/filters/Duration.svelte new file mode 100644 index 0000000..b482b9c --- /dev/null +++ b/web/frontend/src/filters/Duration.svelte @@ -0,0 +1,95 @@ + + + (isOpen = !isOpen)}> + + Select Start Time + + +

    Between

    + + +
    + +
    +
    h
    +
    +
    + + +
    + +
    +
    m
    +
    +
    + +
    +

    and

    + + +
    + +
    +
    h
    +
    +
    + + +
    + +
    +
    m
    +
    +
    + +
    +
    + + + + + +
    diff --git a/web/frontend/src/filters/Filters.svelte b/web/frontend/src/filters/Filters.svelte new file mode 100644 index 0000000..410f445 --- /dev/null +++ b/web/frontend/src/filters/Filters.svelte @@ -0,0 +1,323 @@ + + + + + + + + + Filters + + + + Manage Filters + + {#if menuText} + {menuText} + + {/if} + (isClusterOpen = true)}> + Cluster/Partition + + (isJobStatesOpen = true)}> + Job States + + (isStartTimeOpen = true)}> + Start Time + + (isDurationOpen = true)}> + Duration + + (isTagsOpen = true)}> + Tags + + (isResourcesOpen = true)}> + Nodes/Accelerators + + (isStatsOpen = true)}> + (isStatsOpen = true)}/> Statistics + + {#if startTimeQuickSelect} + + Start Time Qick Selection + {#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}} + { + filters.startTime.from = (new Date(Date.now() - seconds * 1000)).toISOString() + filters.startTime.to = (new Date(Date.now())).toISOString() + update() + }}> + {text} + + {/each} + {/if} + + + + + + {#if filters.cluster} + (isClusterOpen = true)}> + {filters.cluster} + {#if filters.partition} + ({filters.partition}) + {/if} + + {/if} + + {#if filters.states.length != allJobStates.length} + (isJobStatesOpen = true)}> + {filters.states.join(', ')} + + {/if} + + {#if filters.startTime.from || filters.startTime.to} + (isStartTimeOpen = true)}> + {new Date(filters.startTime.from).toLocaleString()} - {new Date(filters.startTime.to).toLocaleString()} + + {/if} + + {#if filters.duration.from || filters.duration.to} + (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 + + {/if} + + {#if filters.tags.length != 0} + (isTagsOpen = true)}> + {#each filters.tags as tagId} + + {/each} + + {/if} + + {#if filters.numNodes.from != null || filters.numNodes.to != null} + (isResourcesOpen = true)}> + Nodes: {filters.numNodes.from} - {filters.numNodes.to} + + {/if} + + {#if filters.stats.length > 0} + (isStatsOpen = true)}> + {filters.stats.map(stat => `${stat.text}: ${stat.from} - ${stat.to}`).join(', ')} + + {/if} + + + + update()} /> + + update()} /> + + update()} /> + + update()} /> + + update()} /> + + update()} /> + + update()} /> + + diff --git a/web/frontend/src/filters/InfoBox.svelte b/web/frontend/src/filters/InfoBox.svelte new file mode 100644 index 0000000..58fc8a5 --- /dev/null +++ b/web/frontend/src/filters/InfoBox.svelte @@ -0,0 +1,11 @@ + + + diff --git a/web/frontend/src/filters/JobStates.svelte b/web/frontend/src/filters/JobStates.svelte new file mode 100644 index 0000000..4e5db2e --- /dev/null +++ b/web/frontend/src/filters/JobStates.svelte @@ -0,0 +1,47 @@ + + + + (isOpen = !isOpen)}> + + Select Job States + + + + {#each allJobStates as state} + + + {state} + + {/each} + + + + + + + + diff --git a/web/frontend/src/filters/Resources.svelte b/web/frontend/src/filters/Resources.svelte new file mode 100644 index 0000000..4f895b5 --- /dev/null +++ b/web/frontend/src/filters/Resources.svelte @@ -0,0 +1,99 @@ + + + (isOpen = !isOpen)}> + + Select Number of Nodes, HWThreads and Accelerators + + +

    Number of Nodes

    + (pendingNumNodes = { from: detail[0], to: detail[1] })} + min={minNumNodes} max={maxNumNodes} + firstSlider={pendingNumNodes.from} secondSlider={pendingNumNodes.to} /> + + {#if maxNumAccelerators != null && maxNumAccelerators > 1} + (pendingNumAccelerators = { from: detail[0], to: detail[1] })} + min={minNumAccelerators} max={maxNumAccelerators} + firstSlider={pendingNumAccelerators.from} secondSlider={pendingNumAccelerators.to} /> + {/if} +
    + + + + + +
    diff --git a/web/frontend/src/filters/StartTime.svelte b/web/frontend/src/filters/StartTime.svelte new file mode 100644 index 0000000..c89851d --- /dev/null +++ b/web/frontend/src/filters/StartTime.svelte @@ -0,0 +1,90 @@ + + + (isOpen = !isOpen)}> + + Select Start Time + + +

    From

    + + + + + + + + +

    To

    + + + + + + + + +
    + + + + + +
    diff --git a/web/frontend/src/filters/Stats.svelte b/web/frontend/src/filters/Stats.svelte new file mode 100644 index 0000000..e7b658d --- /dev/null +++ b/web/frontend/src/filters/Stats.svelte @@ -0,0 +1,113 @@ + + + (isOpen = !isOpen)}> + + Filter based on statistics (of non-running jobs) + + + {#each statistics as stat} +

    {stat.text}

    + (stat.from = detail[0], stat.to = detail[1], stat.enabled = true)} + min={0} max={stat.peak} + firstSlider={stat.from} secondSlider={stat.to} /> + {/each} +
    + + + + + +
    diff --git a/web/frontend/src/filters/Tags.svelte b/web/frontend/src/filters/Tags.svelte new file mode 100644 index 0000000..b5a145a --- /dev/null +++ b/web/frontend/src/filters/Tags.svelte @@ -0,0 +1,67 @@ + + + (isOpen = !isOpen)}> + + Select Tags + + + +
    + + {#if $initialized} + {#each fuzzySearchTags(searchTerm, allTags) as tag (tag)} + + {#if pendingTags.includes(tag.id)} + + {:else} + + {/if} + + + + {:else} + No Tags + {/each} + {/if} + +
    + + + + + +
    diff --git a/web/frontend/src/filters/TimeSelection.svelte b/web/frontend/src/filters/TimeSelection.svelte new file mode 100644 index 0000000..7d7cca4 --- /dev/null +++ b/web/frontend/src/filters/TimeSelection.svelte @@ -0,0 +1,80 @@ + + + + + + + {#if timeRange == -1} + from + updateExplicitTimeRange('from', event)}> + to + updateExplicitTimeRange('to', event)}> + {/if} + diff --git a/web/frontend/src/filters/UserOrProject.svelte b/web/frontend/src/filters/UserOrProject.svelte new file mode 100644 index 0000000..7f9f183 --- /dev/null +++ b/web/frontend/src/filters/UserOrProject.svelte @@ -0,0 +1,51 @@ + + + + + termChanged()} on:keyup={(event) => termChanged(event.key == 'Enter' ? 0 : throttle)} + placeholder={mode == 'user' ? 'filter username...' : 'filter project...'} /> + diff --git a/web/frontend/src/header.entrypoint.js b/web/frontend/src/header.entrypoint.js new file mode 100644 index 0000000..25ff134 --- /dev/null +++ b/web/frontend/src/header.entrypoint.js @@ -0,0 +1,10 @@ +import Header from './Header.svelte' + +const headerDomTarget = document.getElementById('svelte-header') + +if (headerDomTarget != null) { + new Header({ + target: headerDomTarget, + props: { ...header }, + }) +} diff --git a/web/frontend/src/job.entrypoint.js b/web/frontend/src/job.entrypoint.js new file mode 100644 index 0000000..f7bceb8 --- /dev/null +++ b/web/frontend/src/job.entrypoint.js @@ -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] + ]) +}) diff --git a/web/frontend/src/joblist/JobInfo.svelte b/web/frontend/src/joblist/JobInfo.svelte new file mode 100644 index 0000000..58472e5 --- /dev/null +++ b/web/frontend/src/joblist/JobInfo.svelte @@ -0,0 +1,88 @@ + + + + +
    +

    + {job.jobId} ({job.cluster}) + {#if job.metaData?.jobName} +
    + {job.metaData.jobName} + {/if} + {#if job.arrayJobId} + Array Job: #{job.arrayJobId} + {/if} +

    + +

    + + + {scrambleNames ? scramble(job.user) : job.user} + + {#if job.userData && job.userData.name} + ({scrambleNames ? scramble(job.userData.name) : job.userData.name}) + {/if} + {#if job.project && job.project != 'no project'} +
    + {job.project} + {/if} +

    + +

    + {job.numNodes} + {#if job.exclusive != 1} + (shared) + {/if} + {#if job.numAcc > 0} + , {job.numAcc} + {/if} + {#if job.numHWThreads > 0} + , {job.numHWThreads} + {/if} +

    + +

    + Start: {(new Date(job.startTime)).toLocaleString()} +
    + Duration: {formatDuration(job.duration)} + {#if job.state == 'running'} + running + {:else if job.state != 'completed'} + {job.state} + {/if} + {#if job.walltime} +
    + Walltime: {formatDuration(job.walltime)} + {/if} +

    + +

    + {#each jobTags as tag} + + {/each} +

    +
    diff --git a/web/frontend/src/joblist/JobList.svelte b/web/frontend/src/joblist/JobList.svelte new file mode 100644 index 0000000..8cdca26 --- /dev/null +++ b/web/frontend/src/joblist/JobList.svelte @@ -0,0 +1,190 @@ + + + + +
    + + + + + {#each metrics as metric (metric)} + + {/each} + + + + {#if $jobs.error} + + + + {:else if $jobs.fetching || !$jobs.data} + + + + {:else if $jobs.data && $initialized} + {#each $jobs.data.jobs.items as job (job)} + + {:else} + + + + {/each} + {/if} + +
    + Job Info + + {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} +
    +

    {$jobs.error.message}

    +
    + +
    + No jobs found +
    +
    +
    + + { + 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 } + }} /> + + diff --git a/web/frontend/src/joblist/Pagination.svelte b/web/frontend/src/joblist/Pagination.svelte new file mode 100644 index 0000000..f7b7453 --- /dev/null +++ b/web/frontend/src/joblist/Pagination.svelte @@ -0,0 +1,230 @@ + + +
    +
    + +
    + + +
    + + { (page - 1) * itemsPerPage } - { Math.min((page - 1) * itemsPerPage + itemsPerPage, totalItems) } of { totalItems } { itemText } + +
    +
    + {#if !backButtonDisabled} + + + {/if} + {#if !nextButtonDisabled} + + {/if} +
    +
    + + + + diff --git a/web/frontend/src/joblist/Refresher.svelte b/web/frontend/src/joblist/Refresher.svelte new file mode 100644 index 0000000..2587711 --- /dev/null +++ b/web/frontend/src/joblist/Refresher.svelte @@ -0,0 +1,43 @@ + + + + + + + \ No newline at end of file diff --git a/web/frontend/src/joblist/Row.svelte b/web/frontend/src/joblist/Row.svelte new file mode 100644 index 0000000..b3a3655 --- /dev/null +++ b/web/frontend/src/joblist/Row.svelte @@ -0,0 +1,101 @@ + + + + + + + + + {#if job.monitoringStatus == 0 || job.monitoringStatus == 2} + + Not monitored or archiving failed + + {:else if $metricsQuery.fetching} + + + + {:else if $metricsQuery.error} + + + {$metricsQuery.error.message.length > 500 + ? $metricsQuery.error.message.substring(0, 499)+'...' + : $metricsQuery.error.message} + + + {:else} + {#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric || i)} + + {#if metric != null} + + {:else} + Missing Data + {/if} + + {/each} + {/if} + diff --git a/web/frontend/src/joblist/SortSelection.svelte b/web/frontend/src/joblist/SortSelection.svelte new file mode 100644 index 0000000..5941964 --- /dev/null +++ b/web/frontend/src/joblist/SortSelection.svelte @@ -0,0 +1,71 @@ + + + + + { isOpen = !isOpen }}> + + Sort rows + + + + {#each sortableColumns as col, i (col)} + + + + {col.text} + + {/each} + + + + + + + + \ No newline at end of file diff --git a/web/frontend/src/jobs.entrypoint.js b/web/frontend/src/jobs.entrypoint.js new file mode 100644 index 0000000..1763a8b --- /dev/null +++ b/web/frontend/src/jobs.entrypoint.js @@ -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] + ]) +}) diff --git a/web/frontend/src/list.entrypoint.js b/web/frontend/src/list.entrypoint.js new file mode 100644 index 0000000..21c8f5d --- /dev/null +++ b/web/frontend/src/list.entrypoint.js @@ -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] + ]) +}) diff --git a/web/frontend/src/node.entrypoint.js b/web/frontend/src/node.entrypoint.js new file mode 100644 index 0000000..e6e6f9a --- /dev/null +++ b/web/frontend/src/node.entrypoint.js @@ -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] + ]) +}) diff --git a/web/frontend/src/plots/Histogram.svelte b/web/frontend/src/plots/Histogram.svelte new file mode 100644 index 0000000..c00de12 --- /dev/null +++ b/web/frontend/src/plots/Histogram.svelte @@ -0,0 +1,210 @@ + + +
    (infoText = '')}> + {infoText} + +
    + + + + + + \ No newline at end of file diff --git a/web/frontend/src/plots/MetricPlot.svelte b/web/frontend/src/plots/MetricPlot.svelte new file mode 100644 index 0000000..d47d813 --- /dev/null +++ b/web/frontend/src/plots/MetricPlot.svelte @@ -0,0 +1,306 @@ + + + + +
    + diff --git a/web/frontend/src/plots/Polar.svelte b/web/frontend/src/plots/Polar.svelte new file mode 100644 index 0000000..6731d8a --- /dev/null +++ b/web/frontend/src/plots/Polar.svelte @@ -0,0 +1,190 @@ +
    + +
    + + diff --git a/web/frontend/src/plots/Roofline.svelte b/web/frontend/src/plots/Roofline.svelte new file mode 100644 index 0000000..d385f0d --- /dev/null +++ b/web/frontend/src/plots/Roofline.svelte @@ -0,0 +1,355 @@ +
    + +
    + + + + diff --git a/web/frontend/src/plots/Scatter.svelte b/web/frontend/src/plots/Scatter.svelte new file mode 100644 index 0000000..f3c955c --- /dev/null +++ b/web/frontend/src/plots/Scatter.svelte @@ -0,0 +1,171 @@ +
    + +
    + + + + diff --git a/web/frontend/src/status.entrypoint.js b/web/frontend/src/status.entrypoint.js new file mode 100644 index 0000000..39c374b --- /dev/null +++ b/web/frontend/src/status.entrypoint.js @@ -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] + ]) +}) diff --git a/web/frontend/src/systems.entrypoint.js b/web/frontend/src/systems.entrypoint.js new file mode 100644 index 0000000..846bd36 --- /dev/null +++ b/web/frontend/src/systems.entrypoint.js @@ -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] + ]) +}) diff --git a/web/frontend/src/user.entrypoint.js b/web/frontend/src/user.entrypoint.js new file mode 100644 index 0000000..0bff82a --- /dev/null +++ b/web/frontend/src/user.entrypoint.js @@ -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] + ]) +}) diff --git a/web/frontend/src/utils.js b/web/frontend/src/utils.js new file mode 100644 index 0000000..decfdc6 --- /dev/null +++ b/web/frontend/src/utils.js @@ -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)) +} diff --git a/web/frontend/yarn.lock b/web/frontend/yarn.lock new file mode 100644 index 0000000..f80e078 --- /dev/null +++ b/web/frontend/yarn.lock @@ -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=