mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2024-11-10 08:57:25 +01:00
Start to fix errors with urql 4
This commit is contained in:
parent
bb20ed655a
commit
52738c7f8e
@ -5,8 +5,8 @@ import resolve from '@rollup/plugin-node-resolve';
|
|||||||
import terser from '@rollup/plugin-terser';
|
import terser from '@rollup/plugin-terser';
|
||||||
import css from 'rollup-plugin-css-only';
|
import css from 'rollup-plugin-css-only';
|
||||||
|
|
||||||
// const production = !process.env.ROLLUP_WATCH;
|
const production = !process.env.ROLLUP_WATCH;
|
||||||
const production = false
|
// const production = false
|
||||||
|
|
||||||
const plugins = [
|
const plugins = [
|
||||||
svelte({
|
svelte({
|
||||||
|
@ -4,12 +4,20 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { init } from "./utils.js";
|
import { init } from "./utils.js";
|
||||||
import { Row, Col, Button, Icon, Table,
|
import {
|
||||||
Card, Spinner, InputGroup, Input, } from "sveltestrap";
|
Row,
|
||||||
|
Col,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
Table,
|
||||||
|
Card,
|
||||||
|
Spinner,
|
||||||
|
InputGroup,
|
||||||
|
Input,
|
||||||
|
} from "sveltestrap";
|
||||||
import Filters from "./filters/Filters.svelte";
|
import Filters from "./filters/Filters.svelte";
|
||||||
import { queryStore, gql, getContextClient } from "@urql/svelte";
|
import { queryStore, gql, getContextClient } from "@urql/svelte";
|
||||||
import { scramble, scrambleNames } from "./joblist/JobInfo.svelte";
|
import { scramble, scrambleNames } from "./joblist/JobInfo.svelte";
|
||||||
import { UniqueInputFieldNamesRule } from "graphql";
|
|
||||||
|
|
||||||
const {} = init();
|
const {} = init();
|
||||||
|
|
||||||
@ -21,9 +29,9 @@
|
|||||||
"Invalid list type provided!"
|
"Invalid list type provided!"
|
||||||
);
|
);
|
||||||
|
|
||||||
let filter = []
|
let filter = [];
|
||||||
|
|
||||||
$: stats = queryStore({
|
const stats = queryStore({
|
||||||
client: getContextClient(),
|
client: getContextClient(),
|
||||||
query: gql`
|
query: gql`
|
||||||
query($filter: [JobFilter!]!) {
|
query($filter: [JobFilter!]!) {
|
||||||
@ -36,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
variables: { filter },
|
variables: { filter },
|
||||||
pause: true
|
pause: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let filters;
|
let filters;
|
||||||
@ -92,7 +100,7 @@
|
|||||||
startTimeQuickSelect={true}
|
startTimeQuickSelect={true}
|
||||||
menuText="Only {type.toLowerCase()}s with jobs that match the filters will show up"
|
menuText="Only {type.toLowerCase()}s with jobs that match the filters will show up"
|
||||||
on:update={({ detail }) => {
|
on:update={({ detail }) => {
|
||||||
$stats.variables = { filter: detail.filters }
|
filter = detail.filters;
|
||||||
stats.resume();
|
stats.resume();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -102,7 +110,10 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">
|
<th scope="col">
|
||||||
{({ USER: "Username", PROJECT: "Project Name" })[type]}
|
<!-- {({ -->
|
||||||
|
<!-- USER: "Username", -->
|
||||||
|
<!-- PROJECT: "Project Name", -->
|
||||||
|
<!-- })[type]} -->
|
||||||
<Button
|
<Button
|
||||||
color={sorting.field == "id" ? "primary" : "light"}
|
color={sorting.field == "id" ? "primary" : "light"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@ -9,84 +9,125 @@
|
|||||||
- update(filters?: [JobFilter])
|
- update(filters?: [JobFilter])
|
||||||
-->
|
-->
|
||||||
<script>
|
<script>
|
||||||
import { queryStore, gql, getContextClient , mutationStore } from '@urql/svelte'
|
import {
|
||||||
import { getContext } from 'svelte';
|
queryStore,
|
||||||
import { Row, Table, Card, Spinner } from 'sveltestrap'
|
gql,
|
||||||
import Pagination from './Pagination.svelte'
|
getContextClient,
|
||||||
import JobListRow from './Row.svelte'
|
mutationStore,
|
||||||
import { stickyHeader } from '../utils.js'
|
} 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'),
|
const ccconfig = getContext("cc-config"),
|
||||||
clusters = getContext('clusters'),
|
clusters = getContext("clusters"),
|
||||||
initialized = getContext('initialized')
|
initialized = getContext("initialized");
|
||||||
|
|
||||||
export let sorting = { field: "startTime", order: "DESC" }
|
export let sorting = { field: "startTime", order: "DESC" };
|
||||||
export let matchedJobs = 0
|
export let matchedJobs = 0;
|
||||||
export let metrics = ccconfig.plot_list_selectedMetrics
|
export let metrics = ccconfig.plot_list_selectedMetrics;
|
||||||
|
|
||||||
let itemsPerPage = ccconfig.plot_list_jobsPerPage
|
let itemsPerPage = ccconfig.plot_list_jobsPerPage;
|
||||||
let page = 1
|
let page = 1;
|
||||||
let paging = { itemsPerPage, page }
|
let paging = { itemsPerPage, page };
|
||||||
let filter = []
|
let filter = [];
|
||||||
|
|
||||||
$: jobs = queryStore({
|
const jobs = queryStore({
|
||||||
client: getContextClient(),
|
client: getContextClient(),
|
||||||
query: gql`
|
query: gql`
|
||||||
query($filter: [JobFilter!]!, $sorting: OrderByInput!, $paging: PageRequest! ){
|
query (
|
||||||
jobs(filter: $filter, order: $sorting, page: $paging) {
|
$filter: [JobFilter!]!
|
||||||
items {
|
$sorting: OrderByInput!
|
||||||
id, jobId, user, project, jobName, cluster, subCluster, startTime,
|
$paging: PageRequest!
|
||||||
duration, numNodes, numHWThreads, numAcc, walltime, resources { hostname },
|
) {
|
||||||
SMT, exclusive, partition, arrayJobId,
|
jobs(filter: $filter, order: $sorting, page: $paging) {
|
||||||
monitoringStatus, state,
|
items {
|
||||||
tags { id, type, name }
|
id
|
||||||
userData { name }
|
jobId
|
||||||
metaData
|
user
|
||||||
|
project
|
||||||
|
jobName
|
||||||
|
cluster
|
||||||
|
subCluster
|
||||||
|
startTime
|
||||||
|
duration
|
||||||
|
numNodes
|
||||||
|
numHWThreads
|
||||||
|
numAcc
|
||||||
|
walltime
|
||||||
|
resources {
|
||||||
|
hostname
|
||||||
|
}
|
||||||
|
SMT
|
||||||
|
exclusive
|
||||||
|
partition
|
||||||
|
arrayJobId
|
||||||
|
monitoringStatus
|
||||||
|
state
|
||||||
|
tags {
|
||||||
|
id
|
||||||
|
type
|
||||||
|
name
|
||||||
|
}
|
||||||
|
userData {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
metaData
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
count
|
`,
|
||||||
}
|
variables: { paging, sorting, filter },
|
||||||
}`,
|
pause: true,
|
||||||
variables: { paging, sorting, filter },
|
});
|
||||||
pause: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateConfiguration = ({ name, value }) => {
|
const updateConfiguration = ({ name, value }) => {
|
||||||
result = mutationStore({
|
result = mutationStore({
|
||||||
client: getContextClient(),
|
client: getContextClient(),
|
||||||
query: gql`mutation($name: String!, $value: String!) {
|
query: gql`
|
||||||
updateConfiguration(name: $name, value: $value)
|
mutation ($name: String!, $value: String!) {
|
||||||
}`,
|
updateConfiguration(name: $name, value: $value)
|
||||||
variables: {name, value}
|
}
|
||||||
})
|
`,
|
||||||
}
|
variables: { name, value },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// $: $jobs.variables = { ...$jobs.variables, sorting, paging }
|
// $: $jobs.variables = { ...$jobs.variables, sorting, paging }
|
||||||
$: matchedJobs = $jobs.data != null ? $jobs.data.jobs.count : 0
|
$: matchedJobs = $jobs.data != null ? $jobs.data.jobs.count : 0;
|
||||||
|
|
||||||
// (Re-)query and optionally set new filters.
|
// (Re-)query and optionally set new filters.
|
||||||
export function update(filters) {
|
export function update(filters) {
|
||||||
if (filters != null) {
|
if (filters != null) {
|
||||||
let minRunningFor = ccconfig.plot_list_hideShortRunningJobs
|
let minRunningFor = ccconfig.plot_list_hideShortRunningJobs;
|
||||||
if (minRunningFor && minRunningFor > 0) {
|
if (minRunningFor && minRunningFor > 0) {
|
||||||
filters.push({ minRunningFor })
|
filters.push({ minRunningFor });
|
||||||
}
|
}
|
||||||
|
|
||||||
$jobs.variables.filter = filters
|
filter = filters;
|
||||||
// console.log('filters:', ...filters.map(f => Object.entries(f)).flat(2))
|
// console.log('filters:', ...filters.map(f => Object.entries(f)).flat(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
page = 1
|
page = 1;
|
||||||
$jobs.variables.paging = paging = { page, itemsPerPage };
|
paging = paging = { page, itemsPerPage };
|
||||||
$jobs.context.pause = false
|
jobs.resume();
|
||||||
$jobs.reexecute({ requestPolicy: 'network-only' })
|
// $jobs.reexecute({ requestPolicy: 'network-only' })
|
||||||
}
|
}
|
||||||
|
|
||||||
let tableWidth = null
|
let tableWidth = null;
|
||||||
let jobInfoColumnWidth = 250
|
let jobInfoColumnWidth = 250;
|
||||||
$: plotWidth = Math.floor((tableWidth - jobInfoColumnWidth) / metrics.length - 10)
|
$: plotWidth = Math.floor(
|
||||||
|
(tableWidth - jobInfoColumnWidth) / metrics.length - 10
|
||||||
|
);
|
||||||
|
|
||||||
let headerPaddingTop = 0
|
let headerPaddingTop = 0;
|
||||||
stickyHeader('.cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)', (x) => (headerPaddingTop = x))
|
stickyHeader(
|
||||||
|
".cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)",
|
||||||
|
(x) => (headerPaddingTop = x)
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
@ -94,20 +135,43 @@
|
|||||||
<Table cellspacing="0px" cellpadding="0px">
|
<Table cellspacing="0px" cellpadding="0px">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="position-sticky top-0" scope="col" style="width: {jobInfoColumnWidth}px; padding-top: {headerPaddingTop}px">
|
<th
|
||||||
|
class="position-sticky top-0"
|
||||||
|
scope="col"
|
||||||
|
style="width: {jobInfoColumnWidth}px; padding-top: {headerPaddingTop}px"
|
||||||
|
>
|
||||||
Job Info
|
Job Info
|
||||||
</th>
|
</th>
|
||||||
{#each metrics as metric (metric)}
|
{#each metrics as metric (metric)}
|
||||||
<th class="position-sticky top-0 text-center" scope="col" style="width: {plotWidth}px; padding-top: {headerPaddingTop}px">
|
<th
|
||||||
|
class="position-sticky top-0 text-center"
|
||||||
|
scope="col"
|
||||||
|
style="width: {plotWidth}px; padding-top: {headerPaddingTop}px"
|
||||||
|
>
|
||||||
{metric}
|
{metric}
|
||||||
{#if $initialized}
|
{#if $initialized}
|
||||||
({clusters
|
({clusters
|
||||||
.map(cluster => cluster.metricConfig.find(m => m.name == metric))
|
.map((cluster) =>
|
||||||
.filter(m => m != null)
|
cluster.metricConfig.find(
|
||||||
.map(m => (m.unit?.prefix?m.unit?.prefix:'') + (m.unit?.base?m.unit?.base:'')) // Build unitStr
|
(m) => m.name == metric
|
||||||
.reduce((arr, unitStr) => arr.includes(unitStr) ? arr : [...arr, unitStr], []) // w/o this, output would be [unitStr, unitStr]
|
)
|
||||||
.join(', ')
|
)
|
||||||
})
|
.filter((m) => m != null)
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
(m.unit?.prefix
|
||||||
|
? m.unit?.prefix
|
||||||
|
: "") +
|
||||||
|
(m.unit?.base ? m.unit?.base : "")
|
||||||
|
) // Build unitStr
|
||||||
|
.reduce(
|
||||||
|
(arr, unitStr) =>
|
||||||
|
arr.includes(unitStr)
|
||||||
|
? arr
|
||||||
|
: [...arr, unitStr],
|
||||||
|
[]
|
||||||
|
) // w/o this, output would be [unitStr, unitStr]
|
||||||
|
.join(", ")})
|
||||||
{/if}
|
{/if}
|
||||||
</th>
|
</th>
|
||||||
{/each}
|
{/each}
|
||||||
@ -116,28 +180,27 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#if $jobs.error}
|
{#if $jobs.error}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{metrics.length + 1}">
|
<td colspan={metrics.length + 1}>
|
||||||
<Card body color="danger" class="mb-3"><h2>{$jobs.error.message}</h2></Card>
|
<Card body color="danger" class="mb-3"
|
||||||
|
><h2>{$jobs.error.message}</h2></Card
|
||||||
|
>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else if $jobs.fetching || !$jobs.data}
|
{:else if $jobs.fetching || !$jobs.data}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{metrics.length + 1}">
|
<td colspan={metrics.length + 1}>
|
||||||
<Spinner secondary />
|
<Spinner secondary />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else if $jobs.data && $initialized}
|
{:else if $jobs.data && $initialized}
|
||||||
{#each $jobs.data.jobs.items as job (job)}
|
{#each $jobs.data.jobs.items as job (job)}
|
||||||
<JobListRow
|
<JobListRow {job} {metrics} {plotWidth} />
|
||||||
job={job}
|
|
||||||
metrics={metrics}
|
|
||||||
plotWidth={plotWidth} />
|
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{metrics.length + 1}">
|
<td colspan={metrics.length + 1}>
|
||||||
No jobs found
|
No jobs found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -146,24 +209,24 @@
|
|||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
bind:page={page}
|
bind:page
|
||||||
{itemsPerPage}
|
{itemsPerPage}
|
||||||
itemText="Jobs"
|
itemText="Jobs"
|
||||||
totalItems={matchedJobs}
|
totalItems={matchedJobs}
|
||||||
on:update={({ detail }) => {
|
on:update={({ detail }) => {
|
||||||
if (detail.itemsPerPage != itemsPerPage) {
|
if (detail.itemsPerPage != itemsPerPage) {
|
||||||
itemsPerPage = detail.itemsPerPage
|
itemsPerPage = detail.itemsPerPage;
|
||||||
updateConfiguration({
|
updateConfiguration({
|
||||||
name: "plot_list_jobsPerPage",
|
name: "plot_list_jobsPerPage",
|
||||||
value: itemsPerPage.toString()
|
value: itemsPerPage.toString(),
|
||||||
}).then(res => {
|
}).then((res) => {
|
||||||
if (res.error)
|
if (res.error) console.error(res.error);
|
||||||
console.error(res.error);
|
});
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page }
|
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page };
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.cc-table-wrapper {
|
.cc-table-wrapper {
|
||||||
|
@ -9,136 +9,173 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { queryStore, gql, getContextClient } from '@urql/svelte'
|
import { queryStore, gql, getContextClient } from "@urql/svelte";
|
||||||
import { getContext } from 'svelte'
|
import { getContext } from "svelte";
|
||||||
import { Card, Spinner } from 'sveltestrap'
|
import { Card, Spinner } from "sveltestrap";
|
||||||
import MetricPlot from '../plots/MetricPlot.svelte'
|
import MetricPlot from "../plots/MetricPlot.svelte";
|
||||||
import JobInfo from './JobInfo.svelte'
|
import JobInfo from "./JobInfo.svelte";
|
||||||
import { maxScope } from '../utils.js'
|
import { maxScope } from "../utils.js";
|
||||||
|
|
||||||
export let job
|
export let job;
|
||||||
export let metrics
|
export let metrics;
|
||||||
export let plotWidth
|
export let plotWidth;
|
||||||
export let plotHeight = 275
|
export let plotHeight = 275;
|
||||||
|
|
||||||
let scopes = [job.numNodes == 1 ? 'core' : 'node']
|
let { id } = job;
|
||||||
|
let scopes = [job.numNodes == 1 ? "core" : "node"];
|
||||||
|
|
||||||
const cluster = getContext('clusters').find(c => c.name == job.cluster)
|
const cluster = getContext("clusters").find((c) => c.name == job.cluster);
|
||||||
// Get all MetricConfs which include subCluster-specific settings for this job
|
// Get all MetricConfs which include subCluster-specific settings for this job
|
||||||
const metricConfig = getContext('metrics')
|
const metricConfig = getContext("metrics");
|
||||||
const metricsQuery = queryStore({
|
const metricsQuery = queryStore({
|
||||||
client: getContextClient(),
|
client: getContextClient(),
|
||||||
query: gql`
|
query: gql`
|
||||||
query($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!) {
|
query ($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!) {
|
||||||
jobMetrics(id: $id, metrics: $metrics, scopes: $scopes) {
|
jobMetrics(id: $id, metrics: $metrics, scopes: $scopes) {
|
||||||
name
|
name
|
||||||
scope
|
scope
|
||||||
metric {
|
metric {
|
||||||
unit { prefix, base }, timestep
|
unit {
|
||||||
statisticsSeries { min, mean, max }
|
prefix
|
||||||
series {
|
base
|
||||||
hostname, id, data
|
}
|
||||||
statistics { min, avg, max }
|
timestep
|
||||||
}
|
statisticsSeries {
|
||||||
}
|
min
|
||||||
}
|
mean
|
||||||
}`,
|
max
|
||||||
pause: true,
|
}
|
||||||
variables: {
|
series {
|
||||||
id: job.id,
|
hostname
|
||||||
metrics,
|
id
|
||||||
scopes}
|
data
|
||||||
})
|
statistics {
|
||||||
|
min
|
||||||
const selectScope = (jobMetrics) => jobMetrics.reduce(
|
avg
|
||||||
(a, b) => maxScope([a.scope, b.scope]) == a.scope
|
max
|
||||||
? (job.numNodes > 1 ? a : b)
|
}
|
||||||
: (job.numNodes > 1 ? b : a), jobMetrics[0])
|
}
|
||||||
|
|
||||||
const sortAndSelectScope = (jobMetrics) => metrics
|
|
||||||
.map(function(name) {
|
|
||||||
// Get MetricConf for this selected/requested metric
|
|
||||||
let thisConfig = metricConfig(cluster, name)
|
|
||||||
let thisSCIndex = thisConfig.subClusters.findIndex(sc => sc.name == job.subCluster)
|
|
||||||
// Check if Subcluster has MetricConf: If not found (index == -1), no further remove flag check required
|
|
||||||
if (thisSCIndex >= 0) {
|
|
||||||
// SubCluster Config present: Check if remove flag is set
|
|
||||||
if (thisConfig.subClusters[thisSCIndex].remove == true) {
|
|
||||||
// Return null data and informational flag
|
|
||||||
return {removed: true, data: null}
|
|
||||||
} else {
|
|
||||||
// load and return metric, if data available
|
|
||||||
let thisMetric = jobMetrics.filter(jobMetric => jobMetric.name == name) // Returns Array
|
|
||||||
if (thisMetric.length > 0) {
|
|
||||||
return {removed: false, data: thisMetric}
|
|
||||||
} else {
|
|
||||||
return {removed: false, data: null}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// No specific subCluster config: 'remove' flag not set, deemed false -> load and return metric, if data available
|
`,
|
||||||
let thisMetric = jobMetrics.filter(jobMetric => jobMetric.name == name) // Returns Array
|
pause: true,
|
||||||
if (thisMetric.length > 0) {
|
variables: {
|
||||||
return {removed: false, data: thisMetric}
|
id,
|
||||||
|
metrics,
|
||||||
|
scopes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectScope = (jobMetrics) =>
|
||||||
|
jobMetrics.reduce(
|
||||||
|
(a, b) =>
|
||||||
|
maxScope([a.scope, b.scope]) == a.scope
|
||||||
|
? job.numNodes > 1
|
||||||
|
? a
|
||||||
|
: b
|
||||||
|
: job.numNodes > 1
|
||||||
|
? b
|
||||||
|
: a,
|
||||||
|
jobMetrics[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortAndSelectScope = (jobMetrics) =>
|
||||||
|
metrics
|
||||||
|
.map(function (name) {
|
||||||
|
// Get MetricConf for this selected/requested metric
|
||||||
|
let thisConfig = metricConfig(cluster, name);
|
||||||
|
let thisSCIndex = thisConfig.subClusters.findIndex(
|
||||||
|
(sc) => sc.name == job.subCluster
|
||||||
|
);
|
||||||
|
// Check if Subcluster has MetricConf: If not found (index == -1), no further remove flag check required
|
||||||
|
if (thisSCIndex >= 0) {
|
||||||
|
// SubCluster Config present: Check if remove flag is set
|
||||||
|
if (thisConfig.subClusters[thisSCIndex].remove == true) {
|
||||||
|
// Return null data and informational flag
|
||||||
|
return { removed: true, data: null };
|
||||||
|
} else {
|
||||||
|
// load and return metric, if data available
|
||||||
|
let thisMetric = jobMetrics.filter(
|
||||||
|
(jobMetric) => jobMetric.name == name
|
||||||
|
); // Returns Array
|
||||||
|
if (thisMetric.length > 0) {
|
||||||
|
return { removed: false, data: thisMetric };
|
||||||
|
} else {
|
||||||
|
return { removed: false, data: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return {removed: false, data: null}
|
// No specific subCluster config: 'remove' flag not set, deemed false -> load and return metric, if data available
|
||||||
|
let thisMetric = jobMetrics.filter(
|
||||||
|
(jobMetric) => jobMetric.name == name
|
||||||
|
); // Returns Array
|
||||||
|
if (thisMetric.length > 0) {
|
||||||
|
return { removed: false, data: thisMetric };
|
||||||
|
} else {
|
||||||
|
return { removed: false, data: null };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
.map(function (jobMetrics) {
|
||||||
.map(function(jobMetrics) {
|
if (jobMetrics.data != null && jobMetrics.data.length > 0) {
|
||||||
if (jobMetrics.data != null && jobMetrics.data.length > 0) {
|
return {
|
||||||
return {removed: jobMetrics.removed, data: selectScope(jobMetrics.data)}
|
removed: jobMetrics.removed,
|
||||||
} else {
|
data: selectScope(jobMetrics.data),
|
||||||
return jobMetrics
|
};
|
||||||
}
|
} else {
|
||||||
})
|
return jobMetrics;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$: metricsQuery.variables = { id: job.id, metrics, scopes }
|
// $: metricsQuery.variables = { id: job.id, metrics, scopes };
|
||||||
|
|
||||||
if (job.monitoringStatus)
|
if (job.monitoringStatus) metricsQuery.resume();
|
||||||
$metricsQuery.resume()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<JobInfo job={job}/>
|
<JobInfo {job} />
|
||||||
</td>
|
</td>
|
||||||
{#if job.monitoringStatus == 0 || job.monitoringStatus == 2}
|
{#if job.monitoringStatus == 0 || job.monitoringStatus == 2}
|
||||||
<td colspan="{metrics.length}">
|
<td colspan={metrics.length}>
|
||||||
<Card body color="warning">Not monitored or archiving failed</Card>
|
<Card body color="warning">Not monitored or archiving failed</Card>
|
||||||
</td>
|
</td>
|
||||||
{:else if $metricsQuery.fetching}
|
{:else if $metricsQuery.fetching}
|
||||||
<td colspan="{metrics.length}" style="text-align: center;">
|
<td colspan={metrics.length} style="text-align: center;">
|
||||||
<Spinner secondary />
|
<Spinner secondary />
|
||||||
</td>
|
</td>
|
||||||
{:else if $metricsQuery.error}
|
{:else if $metricsQuery.error}
|
||||||
<td colspan="{metrics.length}">
|
<td colspan={metrics.length}>
|
||||||
<Card body color="danger" class="mb-3">
|
<Card body color="danger" class="mb-3">
|
||||||
{$metricsQuery.error.message.length > 500
|
{$metricsQuery.error.message.length > 500
|
||||||
? $metricsQuery.error.message.substring(0, 499)+'...'
|
? $metricsQuery.error.message.substring(0, 499) + "..."
|
||||||
: $metricsQuery.error.message}
|
: $metricsQuery.error.message}
|
||||||
</Card>
|
</Card>
|
||||||
</td>
|
</td>
|
||||||
{:else}
|
{:else}
|
||||||
{#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric || i)}
|
{#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric || i)}
|
||||||
<td>
|
<td>
|
||||||
<!-- Subluster Metricconfig remove keyword for jobtables (joblist main, user joblist, project joblist) to be used here as toplevel case-->
|
<!-- Subluster Metricconfig remove keyword for jobtables (joblist main, user joblist, project joblist) to be used here as toplevel case-->
|
||||||
{#if metric.removed == false && metric.data != null}
|
{#if metric.removed == false && metric.data != null}
|
||||||
<MetricPlot
|
<MetricPlot
|
||||||
width={plotWidth}
|
width={plotWidth}
|
||||||
height={plotHeight}
|
height={plotHeight}
|
||||||
timestep={metric.data.metric.timestep}
|
timestep={metric.data.metric.timestep}
|
||||||
scope={metric.data.scope}
|
scope={metric.data.scope}
|
||||||
series={metric.data.metric.series}
|
series={metric.data.metric.series}
|
||||||
statisticsSeries={metric.data.metric.statisticsSeries}
|
statisticsSeries={metric.data.metric.statisticsSeries}
|
||||||
metric={metric.data.name}
|
metric={metric.data.name}
|
||||||
cluster={cluster}
|
{cluster}
|
||||||
subCluster={job.subCluster} />
|
subCluster={job.subCluster}
|
||||||
{:else if metric.removed == true && metric.data == null}
|
/>
|
||||||
<Card body color="info">Metric disabled for subcluster '{ job.subCluster }'</Card>
|
{:else if metric.removed == true && metric.data == null}
|
||||||
{:else}
|
<Card body color="info"
|
||||||
<Card body color="warning">Missing Data</Card>
|
>Metric disabled for subcluster '{job.subCluster}'</Card
|
||||||
{/if}
|
>
|
||||||
|
{:else}
|
||||||
|
<Card body color="warning">Missing Data</Card>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { expiringCacheExchange } from "./cache-exchange.js";
|
import { expiringCacheExchange } from "./cache-exchange.js";
|
||||||
import {
|
import {
|
||||||
CreateClient,
|
Client,
|
||||||
setContextClient,
|
setContextClient,
|
||||||
fetchExchange,
|
fetchExchange,
|
||||||
} from "@urql/svelte";
|
} from "@urql/svelte";
|
||||||
import { setContext, getContext, hasContext, onDestroy, tick } from "svelte";
|
import { setContext, getContext, hasContext, onDestroy, tick } from "svelte";
|
||||||
import { readable } from "svelte/store";
|
import { readable } from "svelte/store";
|
||||||
@ -18,28 +18,28 @@ import { readable } from "svelte/store";
|
|||||||
* - Adds 'metrics' to the context, a function that takes a cluster and metric name and returns the MetricConfig (or undefined)
|
* - Adds 'metrics' to the context, a function that takes a cluster and metric name and returns the MetricConfig (or undefined)
|
||||||
*/
|
*/
|
||||||
export function init(extraInitQuery = "") {
|
export function init(extraInitQuery = "") {
|
||||||
const jwt = hasContext("jwt")
|
const jwt = hasContext("jwt")
|
||||||
? getContext("jwt")
|
? getContext("jwt")
|
||||||
: getContext("cc-config")["jwt"];
|
: getContext("cc-config")["jwt"];
|
||||||
|
|
||||||
const client = CreateClient({
|
const client = new Client({
|
||||||
url: `${window.location.origin}/query`,
|
url: `${window.location.origin}/query`,
|
||||||
fetchOptions:
|
fetchOptions:
|
||||||
jwt != null ? { headers: { Authorization: `Bearer ${jwt}` } } : {},
|
jwt != null ? { headers: { Authorization: `Bearer ${jwt}` } } : {},
|
||||||
exchanges: [
|
exchanges: [
|
||||||
expiringCacheExchange({
|
expiringCacheExchange({
|
||||||
ttl: 5 * 60 * 1000,
|
ttl: 5 * 60 * 1000,
|
||||||
maxSize: 150,
|
maxSize: 150,
|
||||||
}),
|
}),
|
||||||
fetchExchange,
|
fetchExchange,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
setContextClient(client);
|
setContextClient(client);
|
||||||
|
|
||||||
const query = client
|
const query = client
|
||||||
.query(
|
.query(
|
||||||
`query {
|
`query {
|
||||||
clusters {
|
clusters {
|
||||||
name,
|
name,
|
||||||
metricConfig {
|
metricConfig {
|
||||||
@ -68,246 +68,246 @@ export function init(extraInitQuery = "") {
|
|||||||
tags { id, name, type }
|
tags { id, name, type }
|
||||||
${extraInitQuery}
|
${extraInitQuery}
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
.toPromise();
|
.toPromise();
|
||||||
|
|
||||||
let state = { fetching: true, error: null, data: null };
|
let state = { fetching: true, error: null, data: null };
|
||||||
let subscribers = [];
|
let subscribers = [];
|
||||||
const subscribe = (callback) => {
|
const subscribe = (callback) => {
|
||||||
callback(state);
|
callback(state);
|
||||||
subscribers.push(callback);
|
subscribers.push(callback);
|
||||||
return () => {
|
return () => {
|
||||||
subscribers = subscribers.filter((cb) => cb != callback);
|
subscribers = subscribers.filter((cb) => cb != callback);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const tags = [],
|
const tags = [],
|
||||||
clusters = [];
|
clusters = [];
|
||||||
setContext("tags", tags);
|
setContext("tags", tags);
|
||||||
setContext("clusters", clusters);
|
setContext("clusters", clusters);
|
||||||
setContext("metrics", (cluster, metric) => {
|
setContext("metrics", (cluster, metric) => {
|
||||||
if (typeof cluster !== "object")
|
if (typeof cluster !== "object")
|
||||||
cluster = clusters.find((c) => c.name == cluster);
|
cluster = clusters.find((c) => c.name == cluster);
|
||||||
|
|
||||||
return cluster.metricConfig.find((m) => m.name == metric);
|
return cluster.metricConfig.find((m) => m.name == metric);
|
||||||
});
|
});
|
||||||
setContext("on-init", (callback) =>
|
setContext("on-init", (callback) =>
|
||||||
state.fetching ? subscribers.push(callback) : callback(state)
|
state.fetching ? subscribers.push(callback) : callback(state)
|
||||||
);
|
);
|
||||||
setContext(
|
setContext(
|
||||||
"initialized",
|
"initialized",
|
||||||
readable(false, (set) => subscribers.push(() => set(true)))
|
readable(false, (set) => subscribers.push(() => set(true)))
|
||||||
);
|
);
|
||||||
|
|
||||||
query.then(({ error, data }) => {
|
query.then(({ error, data }) => {
|
||||||
state.fetching = false;
|
state.fetching = false;
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
state.error = error;
|
state.error = error;
|
||||||
tick().then(() => subscribers.forEach((cb) => cb(state)));
|
tick().then(() => subscribers.forEach((cb) => cb(state)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let tag of data.tags) tags.push(tag);
|
for (let tag of data.tags) tags.push(tag);
|
||||||
|
|
||||||
for (let cluster of data.clusters) clusters.push(cluster);
|
for (let cluster of data.clusters) clusters.push(cluster);
|
||||||
|
|
||||||
state.data = data;
|
state.data = data;
|
||||||
tick().then(() => subscribers.forEach((cb) => cb(state)));
|
tick().then(() => subscribers.forEach((cb) => cb(state)));
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query: { subscribe },
|
query: { subscribe },
|
||||||
tags,
|
tags,
|
||||||
clusters,
|
clusters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNumber(x) {
|
export function formatNumber(x) {
|
||||||
let suffix = "";
|
let suffix = "";
|
||||||
if (x >= 1000000000) {
|
if (x >= 1000000000) {
|
||||||
x /= 1000000;
|
x /= 1000000;
|
||||||
suffix = "G";
|
suffix = "G";
|
||||||
} else if (x >= 1000000) {
|
} else if (x >= 1000000) {
|
||||||
x /= 1000000;
|
x /= 1000000;
|
||||||
suffix = "M";
|
suffix = "M";
|
||||||
} else if (x >= 1000) {
|
} else if (x >= 1000) {
|
||||||
x /= 1000;
|
x /= 1000;
|
||||||
suffix = "k";
|
suffix = "k";
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${Math.round(x * 100) / 100} ${suffix}`;
|
return `${Math.round(x * 100) / 100} ${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use https://developer.mozilla.org/en-US/docs/Web/API/structuredClone instead?
|
// Use https://developer.mozilla.org/en-US/docs/Web/API/structuredClone instead?
|
||||||
export function deepCopy(x) {
|
export function deepCopy(x) {
|
||||||
return JSON.parse(JSON.stringify(x));
|
return JSON.parse(JSON.stringify(x));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fuzzyMatch(term, string) {
|
function fuzzyMatch(term, string) {
|
||||||
return string.toLowerCase().includes(term);
|
return string.toLowerCase().includes(term);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fuzzySearchTags(term, tags) {
|
export function fuzzySearchTags(term, tags) {
|
||||||
if (!tags) return [];
|
if (!tags) return [];
|
||||||
|
|
||||||
let results = [];
|
let results = [];
|
||||||
let termparts = term
|
let termparts = term
|
||||||
.split(":")
|
.split(":")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter((s) => s.length > 0);
|
.filter((s) => s.length > 0);
|
||||||
|
|
||||||
if (termparts.length == 0) {
|
if (termparts.length == 0) {
|
||||||
results = tags.slice();
|
results = tags.slice();
|
||||||
} else if (termparts.length == 1) {
|
} else if (termparts.length == 1) {
|
||||||
for (let tag of tags)
|
for (let tag of tags)
|
||||||
if (
|
if (
|
||||||
fuzzyMatch(termparts[0], tag.type) ||
|
fuzzyMatch(termparts[0], tag.type) ||
|
||||||
fuzzyMatch(termparts[0], tag.name)
|
fuzzyMatch(termparts[0], tag.name)
|
||||||
)
|
)
|
||||||
results.push(tag);
|
results.push(tag);
|
||||||
} else if (termparts.length == 2) {
|
} else if (termparts.length == 2) {
|
||||||
for (let tag of tags)
|
for (let tag of tags)
|
||||||
if (
|
if (
|
||||||
fuzzyMatch(termparts[0], tag.type) &&
|
fuzzyMatch(termparts[0], tag.type) &&
|
||||||
fuzzyMatch(termparts[1], tag.name)
|
fuzzyMatch(termparts[1], tag.name)
|
||||||
)
|
)
|
||||||
results.push(tag);
|
results.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
if (a.type < b.type) return -1;
|
if (a.type < b.type) return -1;
|
||||||
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;
|
||||||
if (a.name > b.name) return 1;
|
if (a.name > b.name) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupByScope(jobMetrics) {
|
export function groupByScope(jobMetrics) {
|
||||||
let metrics = new Map();
|
let metrics = new Map();
|
||||||
for (let metric of jobMetrics) {
|
for (let metric of jobMetrics) {
|
||||||
if (metrics.has(metric.name)) metrics.get(metric.name).push(metric);
|
if (metrics.has(metric.name)) metrics.get(metric.name).push(metric);
|
||||||
else metrics.set(metric.name, [metric]);
|
else metrics.set(metric.name, [metric]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...metrics.values()].sort((a, b) =>
|
return [...metrics.values()].sort((a, b) =>
|
||||||
a[0].name.localeCompare(b[0].name)
|
a[0].name.localeCompare(b[0].name)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopeGranularity = {
|
const scopeGranularity = {
|
||||||
node: 10,
|
node: 10,
|
||||||
socket: 5,
|
socket: 5,
|
||||||
accelerator: 5,
|
accelerator: 5,
|
||||||
core: 2,
|
core: 2,
|
||||||
hwthread: 1,
|
hwthread: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function maxScope(scopes) {
|
export function maxScope(scopes) {
|
||||||
console.assert(
|
console.assert(
|
||||||
scopes.length > 0 && scopes.every((x) => scopeGranularity[x] != null)
|
scopes.length > 0 && scopes.every((x) => scopeGranularity[x] != null)
|
||||||
);
|
);
|
||||||
let sm = scopes[0],
|
let sm = scopes[0],
|
||||||
gran = scopeGranularity[scopes[0]];
|
gran = scopeGranularity[scopes[0]];
|
||||||
for (let scope of scopes) {
|
for (let scope of scopes) {
|
||||||
let otherGran = scopeGranularity[scope];
|
let otherGran = scopeGranularity[scope];
|
||||||
if (otherGran > gran) {
|
if (otherGran > gran) {
|
||||||
sm = scope;
|
sm = scope;
|
||||||
gran = otherGran;
|
gran = otherGran;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return sm;
|
||||||
return sm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function minScope(scopes) {
|
export function minScope(scopes) {
|
||||||
console.assert(
|
console.assert(
|
||||||
scopes.length > 0 && scopes.every((x) => scopeGranularity[x] != null)
|
scopes.length > 0 && scopes.every((x) => scopeGranularity[x] != null)
|
||||||
);
|
);
|
||||||
let sm = scopes[0],
|
let sm = scopes[0],
|
||||||
gran = scopeGranularity[scopes[0]];
|
gran = scopeGranularity[scopes[0]];
|
||||||
for (let scope of scopes) {
|
for (let scope of scopes) {
|
||||||
let otherGran = scopeGranularity[scope];
|
let otherGran = scopeGranularity[scope];
|
||||||
if (otherGran < gran) {
|
if (otherGran < gran) {
|
||||||
sm = scope;
|
sm = scope;
|
||||||
gran = otherGran;
|
gran = otherGran;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return sm;
|
||||||
return sm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMetrics(job, metrics, scopes) {
|
export async function fetchMetrics(job, metrics, scopes) {
|
||||||
if (job.monitoringStatus == 0) return null;
|
if (job.monitoringStatus == 0) return null;
|
||||||
|
|
||||||
let query = [];
|
let query = [];
|
||||||
if (metrics != null) {
|
if (metrics != null) {
|
||||||
for (let metric of metrics) {
|
for (let metric of metrics) {
|
||||||
query.push(`metric=${metric}`);
|
query.push(`metric=${metric}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (scopes != null) {
|
||||||
if (scopes != null) {
|
for (let scope of scopes) {
|
||||||
for (let scope of scopes) {
|
query.push(`scope=${scope}`);
|
||||||
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();
|
try {
|
||||||
} catch (e) {
|
let res = await fetch(
|
||||||
return { error: e };
|
`/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() {
|
export function fetchMetricsStore() {
|
||||||
let set = null;
|
let set = null;
|
||||||
let prev = { fetching: true, error: null, data: null };
|
let prev = { fetching: true, error: null, data: null };
|
||||||
return [
|
return [
|
||||||
readable(prev, (_set) => {
|
readable(prev, (_set) => {
|
||||||
set = _set;
|
set = _set;
|
||||||
}),
|
}),
|
||||||
(job, metrics, scopes) =>
|
(job, metrics, scopes) =>
|
||||||
fetchMetrics(job, metrics, scopes).then((res) => {
|
fetchMetrics(job, metrics, scopes).then((res) => {
|
||||||
let next = { fetching: false, error: res.error, data: res.data };
|
let next = { fetching: false, error: res.error, data: res.data };
|
||||||
if (prev.data && next.data)
|
if (prev.data && next.data)
|
||||||
next.data.jobMetrics.push(...prev.data.jobMetrics);
|
next.data.jobMetrics.push(...prev.data.jobMetrics);
|
||||||
|
|
||||||
prev = next;
|
prev = next;
|
||||||
set(next);
|
set(next);
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stickyHeader(datatableHeaderSelector, updatePading) {
|
export function stickyHeader(datatableHeaderSelector, updatePading) {
|
||||||
const header = document.querySelector("header > nav.navbar");
|
const header = document.querySelector("header > nav.navbar");
|
||||||
if (!header) return;
|
if (!header) return;
|
||||||
|
|
||||||
let ticking = false,
|
let ticking = false,
|
||||||
datatableHeader = null;
|
datatableHeader = null;
|
||||||
const onscroll = (event) => {
|
const onscroll = (event) => {
|
||||||
if (ticking) return;
|
if (ticking) return;
|
||||||
|
|
||||||
ticking = true;
|
ticking = true;
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
ticking = false;
|
ticking = false;
|
||||||
if (!datatableHeader)
|
if (!datatableHeader)
|
||||||
datatableHeader = document.querySelector(datatableHeaderSelector);
|
datatableHeader = document.querySelector(datatableHeaderSelector);
|
||||||
|
|
||||||
const top = datatableHeader.getBoundingClientRect().top;
|
const top = datatableHeader.getBoundingClientRect().top;
|
||||||
updatePading(
|
updatePading(
|
||||||
top < header.clientHeight ? header.clientHeight - top + 10 : 10
|
top < header.clientHeight ? header.clientHeight - top + 10 : 10
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("scroll", onscroll);
|
document.addEventListener("scroll", onscroll);
|
||||||
onDestroy(() => document.removeEventListener("scroll", onscroll));
|
onDestroy(() => document.removeEventListener("scroll", onscroll));
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user