mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-07-27 06:36:07 +02:00
Upgrade frontend dependencies
Change to most recent @sveltestrap/sveltestrap Reformat with Svelte LSP
This commit is contained in:
@@ -6,115 +6,138 @@
|
||||
- jobTags: Defaults to job.tags, usefull for dynamically updating the tags.
|
||||
-->
|
||||
<script context="module">
|
||||
export const scrambleNames = window.localStorage.getItem("cc-scramble-names")
|
||||
export const scramble = function(str) {
|
||||
if (str === '-') return str
|
||||
else return [...str].reduce((x, c, i) => x * 7 + c.charCodeAt(0) * i * 21, 5).toString(32).substr(0, 6)
|
||||
}
|
||||
export const scrambleNames = window.localStorage.getItem("cc-scramble-names");
|
||||
export const scramble = function (str) {
|
||||
if (str === "-") return str;
|
||||
else
|
||||
return [...str]
|
||||
.reduce((x, c, i) => x * 7 + c.charCodeAt(0) * i * 21, 5)
|
||||
.toString(32)
|
||||
.substr(0, 6);
|
||||
};
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import Tag from '../Tag.svelte';
|
||||
import { Badge, Icon } from 'sveltestrap';
|
||||
import Tag from "../Tag.svelte";
|
||||
import { Badge, Icon } from "@sveltestrap/sveltestrap";
|
||||
|
||||
export let job;
|
||||
export let jobTags = job.tags;
|
||||
export let job;
|
||||
export let jobTags = job.tags;
|
||||
|
||||
function formatDuration(duration) {
|
||||
const hours = Math.floor(duration / 3600);
|
||||
duration -= hours * 3600;
|
||||
const minutes = Math.floor(duration / 60);
|
||||
duration -= minutes * 60;
|
||||
const seconds = duration;
|
||||
return `${hours}:${('0' + minutes).slice(-2)}:${('0' + seconds).slice(-2)}`;
|
||||
function formatDuration(duration) {
|
||||
const hours = Math.floor(duration / 3600);
|
||||
duration -= hours * 3600;
|
||||
const minutes = Math.floor(duration / 60);
|
||||
duration -= minutes * 60;
|
||||
const seconds = duration;
|
||||
return `${hours}:${("0" + minutes).slice(-2)}:${("0" + seconds).slice(-2)}`;
|
||||
}
|
||||
|
||||
function getStateColor(state) {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return "success";
|
||||
case "completed":
|
||||
return "primary";
|
||||
default:
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function getStateColor(state) {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return 'success'
|
||||
case 'completed':
|
||||
return 'primary'
|
||||
default:
|
||||
return 'danger'
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
<span class="fw-bold"><a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a> ({job.cluster})</span>
|
||||
{#if job.metaData?.jobName}
|
||||
<br/>
|
||||
{#if job.metaData?.jobName.length <= 25}
|
||||
<div>{job.metaData.jobName}</div>
|
||||
{:else}
|
||||
<div class="truncate" style="cursor:help; width:230px;" title={job.metaData.jobName}>{job.metaData.jobName}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if job.arrayJobId}
|
||||
Array Job: <a href="/monitoring/jobs/?arrayJobId={job.arrayJobId}&cluster={job.cluster}" target="_blank">#{job.arrayJobId}</a>
|
||||
{/if}
|
||||
</p>
|
||||
<p>
|
||||
<span class="fw-bold"
|
||||
><a href="/monitoring/job/{job.id}" target="_blank">{job.jobId}</a>
|
||||
({job.cluster})</span
|
||||
>
|
||||
{#if job.metaData?.jobName}
|
||||
<br />
|
||||
{#if job.metaData?.jobName.length <= 25}
|
||||
<div>{job.metaData.jobName}</div>
|
||||
{:else}
|
||||
<div
|
||||
class="truncate"
|
||||
style="cursor:help; width:230px;"
|
||||
title={job.metaData.jobName}
|
||||
>
|
||||
{job.metaData.jobName}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if job.arrayJobId}
|
||||
Array Job: <a
|
||||
href="/monitoring/jobs/?arrayJobId={job.arrayJobId}&cluster={job.cluster}"
|
||||
target="_blank">#{job.arrayJobId}</a
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Icon name="person-fill"/>
|
||||
<a class="fst-italic" href="/monitoring/user/{job.user}" target="_blank">
|
||||
{scrambleNames ? scramble(job.user) : job.user}
|
||||
</a>
|
||||
{#if job.userData && job.userData.name}
|
||||
({scrambleNames ? scramble(job.userData.name) : job.userData.name})
|
||||
{/if}
|
||||
{#if job.project && job.project != 'no project'}
|
||||
<br/>
|
||||
<Icon name="people-fill"/>
|
||||
<a class="fst-italic" href="/monitoring/jobs/?project={job.project}&projectMatch=eq" target="_blank">
|
||||
{scrambleNames ? scramble(job.project) : job.project}
|
||||
</a>
|
||||
{/if}
|
||||
</p>
|
||||
<p>
|
||||
<Icon name="person-fill" />
|
||||
<a class="fst-italic" href="/monitoring/user/{job.user}" target="_blank">
|
||||
{scrambleNames ? scramble(job.user) : job.user}
|
||||
</a>
|
||||
{#if job.userData && job.userData.name}
|
||||
({scrambleNames ? scramble(job.userData.name) : job.userData.name})
|
||||
{/if}
|
||||
{#if job.project && job.project != "no project"}
|
||||
<br />
|
||||
<Icon name="people-fill" />
|
||||
<a
|
||||
class="fst-italic"
|
||||
href="/monitoring/jobs/?project={job.project}&projectMatch=eq"
|
||||
target="_blank"
|
||||
>
|
||||
{scrambleNames ? scramble(job.project) : job.project}
|
||||
</a>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{#if job.numNodes == 1}
|
||||
{job.resources[0].hostname}
|
||||
{:else}
|
||||
{job.numNodes}
|
||||
{/if}
|
||||
<Icon name="pc-horizontal"/>
|
||||
{#if job.exclusive != 1}
|
||||
(shared)
|
||||
{/if}
|
||||
{#if job.numAcc > 0}
|
||||
, {job.numAcc} <Icon name="gpu-card"/>
|
||||
{/if}
|
||||
{#if job.numHWThreads > 0}
|
||||
, {job.numHWThreads} <Icon name="cpu"/>
|
||||
{/if}
|
||||
<br/>
|
||||
{job.subCluster}
|
||||
</p>
|
||||
<p>
|
||||
{#if job.numNodes == 1}
|
||||
{job.resources[0].hostname}
|
||||
{:else}
|
||||
{job.numNodes}
|
||||
{/if}
|
||||
<Icon name="pc-horizontal" />
|
||||
{#if job.exclusive != 1}
|
||||
(shared)
|
||||
{/if}
|
||||
{#if job.numAcc > 0}
|
||||
, {job.numAcc} <Icon name="gpu-card" />
|
||||
{/if}
|
||||
{#if job.numHWThreads > 0}
|
||||
, {job.numHWThreads} <Icon name="cpu" />
|
||||
{/if}
|
||||
<br />
|
||||
{job.subCluster}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Start: <span class="fw-bold">{(new Date(job.startTime)).toLocaleString()}</span>
|
||||
<br/>
|
||||
Duration: <span class="fw-bold">{formatDuration(job.duration)}</span> <Badge color="{getStateColor(job.state)}">{job.state}</Badge>
|
||||
{#if job.walltime}
|
||||
<br/>
|
||||
Walltime: <span class="fw-bold">{formatDuration(job.walltime)}</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p>
|
||||
Start: <span class="fw-bold"
|
||||
>{new Date(job.startTime).toLocaleString()}</span
|
||||
>
|
||||
<br />
|
||||
Duration: <span class="fw-bold">{formatDuration(job.duration)}</span>
|
||||
<Badge color={getStateColor(job.state)}>{job.state}</Badge>
|
||||
{#if job.walltime}
|
||||
<br />
|
||||
Walltime: <span class="fw-bold">{formatDuration(job.walltime)}</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{#each jobTags as tag}
|
||||
<Tag tag={tag}/>
|
||||
{/each}
|
||||
</p>
|
||||
<p>
|
||||
{#each jobTags as tag}
|
||||
<Tag {tag} />
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
@@ -9,284 +9,275 @@
|
||||
- update(filters?: [JobFilter])
|
||||
-->
|
||||
<script>
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
getContextClient,
|
||||
mutationStore,
|
||||
} 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";
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
getContextClient,
|
||||
mutationStore,
|
||||
} from "@urql/svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { Row, Table, Card, Spinner } from "@sveltestrap/sveltestrap";
|
||||
import Pagination from "./Pagination.svelte";
|
||||
import JobListRow from "./Row.svelte";
|
||||
import { stickyHeader } from "../utils.js";
|
||||
|
||||
const ccconfig = getContext("cc-config"),
|
||||
clusters = getContext("clusters"),
|
||||
initialized = getContext("initialized");
|
||||
const ccconfig = getContext("cc-config"),
|
||||
clusters = getContext("clusters"),
|
||||
initialized = getContext("initialized");
|
||||
|
||||
export let sorting = { field: "startTime", order: "DESC" };
|
||||
export let matchedJobs = 0;
|
||||
export let metrics = ccconfig.plot_list_selectedMetrics;
|
||||
export let showFootprint;
|
||||
export let sorting = { field: "startTime", order: "DESC" };
|
||||
export let matchedJobs = 0;
|
||||
export let metrics = ccconfig.plot_list_selectedMetrics;
|
||||
export let showFootprint;
|
||||
|
||||
let itemsPerPage = ccconfig.plot_list_jobsPerPage;
|
||||
let page = 1;
|
||||
let paging = { itemsPerPage, page };
|
||||
let filter = [];
|
||||
let itemsPerPage = ccconfig.plot_list_jobsPerPage;
|
||||
let page = 1;
|
||||
let paging = { itemsPerPage, page };
|
||||
let filter = [];
|
||||
|
||||
const client = getContextClient();
|
||||
const query = gql`
|
||||
query (
|
||||
$filter: [JobFilter!]!
|
||||
$sorting: OrderByInput!
|
||||
$paging: PageRequest!
|
||||
) {
|
||||
jobs(filter: $filter, order: $sorting, page: $paging) {
|
||||
items {
|
||||
id
|
||||
jobId
|
||||
user
|
||||
project
|
||||
cluster
|
||||
subCluster
|
||||
startTime
|
||||
duration
|
||||
numNodes
|
||||
numHWThreads
|
||||
numAcc
|
||||
walltime
|
||||
resources {
|
||||
hostname
|
||||
}
|
||||
SMT
|
||||
exclusive
|
||||
partition
|
||||
arrayJobId
|
||||
monitoringStatus
|
||||
state
|
||||
tags {
|
||||
id
|
||||
type
|
||||
name
|
||||
}
|
||||
userData {
|
||||
name
|
||||
}
|
||||
metaData
|
||||
flopsAnyAvg
|
||||
memBwAvg
|
||||
loadAvg
|
||||
}
|
||||
count
|
||||
}
|
||||
const client = getContextClient();
|
||||
const query = gql`
|
||||
query (
|
||||
$filter: [JobFilter!]!
|
||||
$sorting: OrderByInput!
|
||||
$paging: PageRequest!
|
||||
) {
|
||||
jobs(filter: $filter, order: $sorting, page: $paging) {
|
||||
items {
|
||||
id
|
||||
jobId
|
||||
user
|
||||
project
|
||||
cluster
|
||||
subCluster
|
||||
startTime
|
||||
duration
|
||||
numNodes
|
||||
numHWThreads
|
||||
numAcc
|
||||
walltime
|
||||
resources {
|
||||
hostname
|
||||
}
|
||||
SMT
|
||||
exclusive
|
||||
partition
|
||||
arrayJobId
|
||||
monitoringStatus
|
||||
state
|
||||
tags {
|
||||
id
|
||||
type
|
||||
name
|
||||
}
|
||||
userData {
|
||||
name
|
||||
}
|
||||
metaData
|
||||
flopsAnyAvg
|
||||
memBwAvg
|
||||
loadAvg
|
||||
}
|
||||
`;
|
||||
count
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
$: jobs = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { paging, sorting, filter }
|
||||
$: jobs = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { paging, sorting, filter },
|
||||
});
|
||||
|
||||
$: matchedJobs = $jobs.data != null ? $jobs.data.jobs.count : 0;
|
||||
|
||||
// Force refresh list with existing unchanged variables (== usually would not trigger reactivity)
|
||||
export function refresh() {
|
||||
jobs = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { paging, sorting, filter },
|
||||
requestPolicy: "network-only",
|
||||
});
|
||||
}
|
||||
|
||||
$: matchedJobs = $jobs.data != null ? $jobs.data.jobs.count : 0;
|
||||
|
||||
// Force refresh list with existing unchanged variables (== usually would not trigger reactivity)
|
||||
export function refresh() {
|
||||
jobs = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { paging, sorting, filter },
|
||||
requestPolicy: 'network-only'
|
||||
});
|
||||
// (Re-)query and optionally set new filters.
|
||||
export function update(filters) {
|
||||
if (filters != null) {
|
||||
let minRunningFor = ccconfig.plot_list_hideShortRunningJobs;
|
||||
if (minRunningFor && minRunningFor > 0) {
|
||||
filters.push({ minRunningFor });
|
||||
}
|
||||
filter = filters;
|
||||
}
|
||||
page = 1;
|
||||
paging = paging = { page, itemsPerPage };
|
||||
}
|
||||
|
||||
// (Re-)query and optionally set new filters.
|
||||
export function update(filters) {
|
||||
if (filters != null) {
|
||||
let minRunningFor = ccconfig.plot_list_hideShortRunningJobs;
|
||||
if (minRunningFor && minRunningFor > 0) {
|
||||
filters.push({ minRunningFor });
|
||||
}
|
||||
filter = filters;
|
||||
const updateConfigurationMutation = ({ name, value }) => {
|
||||
return mutationStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
mutation ($name: String!, $value: String!) {
|
||||
updateConfiguration(name: $name, value: $value)
|
||||
}
|
||||
page = 1;
|
||||
paging = paging = { page, itemsPerPage };
|
||||
}
|
||||
`,
|
||||
variables: { name, value },
|
||||
});
|
||||
};
|
||||
|
||||
const updateConfigurationMutation = ({ name, value }) => {
|
||||
return mutationStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
mutation ($name: String!, $value: String!) {
|
||||
updateConfiguration(name: $name, value: $value)
|
||||
}
|
||||
`,
|
||||
variables: { name, value }
|
||||
});
|
||||
}
|
||||
function updateConfiguration(value, page) {
|
||||
updateConfigurationMutation({
|
||||
name: "plot_list_jobsPerPage",
|
||||
value: value,
|
||||
}).subscribe((res) => {
|
||||
if (res.fetching === false && !res.error) {
|
||||
paging = { itemsPerPage: value, page: page }; // Trigger reload of jobList
|
||||
} else if (res.fetching === false && res.error) {
|
||||
throw res.error;
|
||||
// console.log('Error on subscription: ' + res.error)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateConfiguration(value, page) {
|
||||
updateConfigurationMutation({ name: 'plot_list_jobsPerPage', value: value })
|
||||
.subscribe(res => {
|
||||
if (res.fetching === false && !res.error) {
|
||||
paging = { itemsPerPage: value, page: page }; // Trigger reload of jobList
|
||||
} else if (res.fetching === false && res.error) {
|
||||
throw res.error
|
||||
// console.log('Error on subscription: ' + res.error)
|
||||
}
|
||||
})
|
||||
};
|
||||
let plotWidth = null;
|
||||
let tableWidth = null;
|
||||
let jobInfoColumnWidth = 250;
|
||||
|
||||
let plotWidth = null;
|
||||
let tableWidth = null;
|
||||
let jobInfoColumnWidth = 250;
|
||||
|
||||
$: if (showFootprint) {
|
||||
plotWidth = Math.floor(
|
||||
(tableWidth - jobInfoColumnWidth) / (metrics.length + 1) - 10
|
||||
)
|
||||
} else {
|
||||
plotWidth = Math.floor(
|
||||
(tableWidth - jobInfoColumnWidth) / metrics.length - 10
|
||||
)
|
||||
}
|
||||
|
||||
let headerPaddingTop = 0;
|
||||
stickyHeader(
|
||||
".cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)",
|
||||
(x) => (headerPaddingTop = x)
|
||||
$: if (showFootprint) {
|
||||
plotWidth = Math.floor(
|
||||
(tableWidth - jobInfoColumnWidth) / (metrics.length + 1) - 10,
|
||||
);
|
||||
} else {
|
||||
plotWidth = Math.floor(
|
||||
(tableWidth - jobInfoColumnWidth) / metrics.length - 10,
|
||||
);
|
||||
}
|
||||
|
||||
let headerPaddingTop = 0;
|
||||
stickyHeader(
|
||||
".cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)",
|
||||
(x) => (headerPaddingTop = x),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Row>
|
||||
<div class="col cc-table-wrapper" bind:clientWidth={tableWidth}>
|
||||
<Table cellspacing="0px" cellpadding="0px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="position-sticky top-0"
|
||||
scope="col"
|
||||
style="width: {jobInfoColumnWidth}px; padding-top: {headerPaddingTop}px"
|
||||
>
|
||||
Job Info
|
||||
</th>
|
||||
{#if showFootprint}
|
||||
<th
|
||||
class="position-sticky top-0"
|
||||
scope="col"
|
||||
style="width: {plotWidth}px; padding-top: {headerPaddingTop}px"
|
||||
>
|
||||
Job Footprint
|
||||
</th>
|
||||
{/if}
|
||||
{#each metrics as metric (metric)}
|
||||
<th
|
||||
class="position-sticky top-0 text-center"
|
||||
scope="col"
|
||||
style="width: {plotWidth}px; padding-top: {headerPaddingTop}px"
|
||||
>
|
||||
{metric}
|
||||
{#if $initialized}
|
||||
({clusters
|
||||
.map((cluster) =>
|
||||
cluster.metricConfig.find(
|
||||
(m) => m.name == metric
|
||||
)
|
||||
)
|
||||
.filter((m) => m != null)
|
||||
.map(
|
||||
(m) =>
|
||||
(m.unit?.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}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $jobs.error}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}>
|
||||
<Card body color="danger" class="mb-3"
|
||||
><h2>{$jobs.error.message}</h2></Card
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{:else if $jobs.fetching || !$jobs.data}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}>
|
||||
<Spinner secondary />
|
||||
</td>
|
||||
</tr>
|
||||
{:else if $jobs.data && $initialized}
|
||||
{#each $jobs.data.jobs.items as job (job)}
|
||||
<JobListRow {job} {metrics} {plotWidth} {showFootprint}/>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}>
|
||||
No jobs found
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<div class="col cc-table-wrapper" bind:clientWidth={tableWidth}>
|
||||
<Table cellspacing="0px" cellpadding="0px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
class="position-sticky top-0"
|
||||
scope="col"
|
||||
style="width: {jobInfoColumnWidth}px; padding-top: {headerPaddingTop}px"
|
||||
>
|
||||
Job Info
|
||||
</th>
|
||||
{#if showFootprint}
|
||||
<th
|
||||
class="position-sticky top-0"
|
||||
scope="col"
|
||||
style="width: {plotWidth}px; padding-top: {headerPaddingTop}px"
|
||||
>
|
||||
Job Footprint
|
||||
</th>
|
||||
{/if}
|
||||
{#each metrics as metric (metric)}
|
||||
<th
|
||||
class="position-sticky top-0 text-center"
|
||||
scope="col"
|
||||
style="width: {plotWidth}px; padding-top: {headerPaddingTop}px"
|
||||
>
|
||||
{metric}
|
||||
{#if $initialized}
|
||||
({clusters
|
||||
.map((cluster) =>
|
||||
cluster.metricConfig.find((m) => m.name == metric),
|
||||
)
|
||||
.filter((m) => m != null)
|
||||
.map(
|
||||
(m) =>
|
||||
(m.unit?.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}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $jobs.error}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}>
|
||||
<Card body color="danger" class="mb-3"
|
||||
><h2>{$jobs.error.message}</h2></Card
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{:else if $jobs.fetching || !$jobs.data}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}>
|
||||
<Spinner secondary />
|
||||
</td>
|
||||
</tr>
|
||||
{:else if $jobs.data && $initialized}
|
||||
{#each $jobs.data.jobs.items as job (job)}
|
||||
<JobListRow {job} {metrics} {plotWidth} {showFootprint} />
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}> No jobs found </td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Pagination
|
||||
bind:page
|
||||
{itemsPerPage}
|
||||
itemText="Jobs"
|
||||
totalItems={matchedJobs}
|
||||
on:update={({ detail }) => {
|
||||
if (detail.itemsPerPage != itemsPerPage) {
|
||||
updateConfiguration(
|
||||
detail.itemsPerPage.toString(),
|
||||
detail.page
|
||||
)
|
||||
} else {
|
||||
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page }
|
||||
}
|
||||
}}
|
||||
bind:page
|
||||
{itemsPerPage}
|
||||
itemText="Jobs"
|
||||
totalItems={matchedJobs}
|
||||
on:update={({ detail }) => {
|
||||
if (detail.itemsPerPage != itemsPerPage) {
|
||||
updateConfiguration(detail.itemsPerPage.toString(), detail.page);
|
||||
} else {
|
||||
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page };
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.cc-table-wrapper {
|
||||
overflow: initial;
|
||||
}
|
||||
.cc-table-wrapper {
|
||||
overflow: initial;
|
||||
}
|
||||
|
||||
.cc-table-wrapper > :global(table) {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.cc-table-wrapper > :global(table) {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.cc-table-wrapper :global(button) {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
.cc-table-wrapper :global(button) {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.cc-table-wrapper > :global(table > tbody > tr > td) {
|
||||
margin: 0px;
|
||||
padding-left: 5px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
.cc-table-wrapper > :global(table > tbody > tr > td) {
|
||||
margin: 0px;
|
||||
padding-left: 5px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
th.position-sticky.top-0 {
|
||||
background-color: white;
|
||||
z-index: 10;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
th.position-sticky.top-0 {
|
||||
background-color: white;
|
||||
z-index: 10;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
</style>
|
||||
|
@@ -5,39 +5,46 @@
|
||||
- 'reload': When fired, the parent component shoud refresh its contents
|
||||
-->
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Button, Icon, InputGroup } from 'sveltestrap'
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { Button, Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let refreshInterval = null;
|
||||
let refreshIntervalId = null;
|
||||
function refreshIntervalChanged() {
|
||||
if (refreshIntervalId != null)
|
||||
clearInterval(refreshIntervalId);
|
||||
let refreshInterval = null;
|
||||
let refreshIntervalId = null;
|
||||
function refreshIntervalChanged() {
|
||||
if (refreshIntervalId != null) clearInterval(refreshIntervalId);
|
||||
|
||||
if (refreshInterval == null)
|
||||
return;
|
||||
if (refreshInterval == null) return;
|
||||
|
||||
refreshIntervalId = setInterval(() => dispatch("reload"), refreshInterval);
|
||||
}
|
||||
refreshIntervalId = setInterval(() => dispatch("reload"), refreshInterval);
|
||||
}
|
||||
|
||||
export let initially = null
|
||||
if (initially != null) {
|
||||
refreshInterval = initially * 1000
|
||||
refreshIntervalChanged()
|
||||
}
|
||||
export let initially = null;
|
||||
if (initially != null) {
|
||||
refreshInterval = initially * 1000;
|
||||
refreshIntervalChanged();
|
||||
}
|
||||
</script>
|
||||
|
||||
<InputGroup>
|
||||
<Button outline on:click={() => dispatch("reload")} disabled={refreshInterval != null}>
|
||||
<Icon name="arrow-clockwise" /> Reload
|
||||
</Button>
|
||||
<select class="form-select" bind:value={refreshInterval} on:change={refreshIntervalChanged}>
|
||||
<option value={null}>No periodic reload</option>
|
||||
<option value={ 30 * 1000}>Update every 30 seconds</option>
|
||||
<option value={ 60 * 1000}>Update every minute</option>
|
||||
<option value={2 * 60 * 1000}>Update every two minutes</option>
|
||||
<option value={5 * 60 * 1000}>Update every 5 minutes</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<Button
|
||||
outline
|
||||
on:click={() => dispatch("reload")}
|
||||
disabled={refreshInterval != null}
|
||||
>
|
||||
<Icon name="arrow-clockwise" /> Reload
|
||||
</Button>
|
||||
<select
|
||||
class="form-select"
|
||||
bind:value={refreshInterval}
|
||||
on:change={refreshIntervalChanged}
|
||||
>
|
||||
<option value={null}>No periodic reload</option>
|
||||
<option value={30 * 1000}>Update every 30 seconds</option>
|
||||
<option value={60 * 1000}>Update every minute</option>
|
||||
<option value={2 * 60 * 1000}>Update every two minutes</option>
|
||||
<option value={5 * 60 * 1000}>Update every 5 minutes</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
|
@@ -9,168 +9,189 @@
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { queryStore, gql, getContextClient } from "@urql/svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { Card, Spinner } from "sveltestrap";
|
||||
import MetricPlot from "../plots/MetricPlot.svelte";
|
||||
import JobInfo from "./JobInfo.svelte";
|
||||
import JobFootprint from "../JobFootprint.svelte";
|
||||
import { maxScope, checkMetricDisabled } from "../utils.js";
|
||||
import { queryStore, gql, getContextClient } from "@urql/svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { Card, Spinner } from "@sveltestrap/sveltestrap";
|
||||
import MetricPlot from "../plots/MetricPlot.svelte";
|
||||
import JobInfo from "./JobInfo.svelte";
|
||||
import JobFootprint from "../JobFootprint.svelte";
|
||||
import { maxScope, checkMetricDisabled } from "../utils.js";
|
||||
|
||||
export let job;
|
||||
export let metrics;
|
||||
export let plotWidth;
|
||||
export let plotHeight = 275;
|
||||
export let showFootprint;
|
||||
export let job;
|
||||
export let metrics;
|
||||
export let plotWidth;
|
||||
export let plotHeight = 275;
|
||||
export let showFootprint;
|
||||
|
||||
let { id } = job;
|
||||
let scopes = [job.numNodes == 1 ? "core" : "node"];
|
||||
let { id } = job;
|
||||
let scopes = [job.numNodes == 1 ? "core" : "node"];
|
||||
|
||||
function distinct(value, index, array) {
|
||||
return array.indexOf(value) === index;
|
||||
}
|
||||
function distinct(value, index, array) {
|
||||
return array.indexOf(value) === index;
|
||||
}
|
||||
|
||||
const cluster = getContext("clusters").find((c) => c.name == job.cluster);
|
||||
const metricConfig = getContext("metrics"); // Get all MetricConfs which include subCluster-specific settings for this job
|
||||
const client = getContextClient();
|
||||
const query = gql`
|
||||
query ($id: ID!, $queryMetrics: [String!]!, $scopes: [MetricScope!]!) {
|
||||
jobMetrics(id: $id, metrics: $queryMetrics, scopes: $scopes) {
|
||||
name
|
||||
scope
|
||||
metric {
|
||||
unit {
|
||||
prefix
|
||||
base
|
||||
}
|
||||
timestep
|
||||
statisticsSeries {
|
||||
min
|
||||
mean
|
||||
max
|
||||
}
|
||||
series {
|
||||
hostname
|
||||
id
|
||||
data
|
||||
statistics {
|
||||
min
|
||||
avg
|
||||
max
|
||||
}
|
||||
}
|
||||
}
|
||||
const cluster = getContext("clusters").find((c) => c.name == job.cluster);
|
||||
const metricConfig = getContext("metrics"); // Get all MetricConfs which include subCluster-specific settings for this job
|
||||
const client = getContextClient();
|
||||
const query = gql`
|
||||
query ($id: ID!, $queryMetrics: [String!]!, $scopes: [MetricScope!]!) {
|
||||
jobMetrics(id: $id, metrics: $queryMetrics, scopes: $scopes) {
|
||||
name
|
||||
scope
|
||||
metric {
|
||||
unit {
|
||||
prefix
|
||||
base
|
||||
}
|
||||
timestep
|
||||
statisticsSeries {
|
||||
min
|
||||
mean
|
||||
max
|
||||
}
|
||||
series {
|
||||
hostname
|
||||
id
|
||||
data
|
||||
statistics {
|
||||
min
|
||||
avg
|
||||
max
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
$: metricsQuery = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { id, queryMetrics, scopes }
|
||||
$: metricsQuery = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { id, queryMetrics, scopes },
|
||||
});
|
||||
|
||||
let queryMetrics = null;
|
||||
$: if (showFootprint) {
|
||||
queryMetrics = [
|
||||
"cpu_load",
|
||||
"flops_any",
|
||||
"mem_used",
|
||||
"mem_bw",
|
||||
"acc_utilization",
|
||||
...metrics,
|
||||
].filter(distinct);
|
||||
scopes = ["node"];
|
||||
} else {
|
||||
queryMetrics = [...metrics];
|
||||
scopes = [job.numNodes == 1 ? "core" : "node"];
|
||||
}
|
||||
|
||||
export function refresh() {
|
||||
metricsQuery = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { id, queryMetrics, scopes },
|
||||
// requestPolicy: 'network-only' // use default cache-first for refresh
|
||||
});
|
||||
}
|
||||
|
||||
let queryMetrics = null
|
||||
$: if (showFootprint) {
|
||||
queryMetrics = ['cpu_load', 'flops_any', 'mem_used', 'mem_bw', 'acc_utilization', ...metrics].filter(distinct)
|
||||
scopes = ["node"]
|
||||
} else {
|
||||
queryMetrics = [...metrics]
|
||||
scopes = [job.numNodes == 1 ? "core" : "node"]
|
||||
}
|
||||
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],
|
||||
);
|
||||
|
||||
export function refresh() {
|
||||
metricsQuery = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { id, queryMetrics, scopes },
|
||||
// requestPolicy: 'network-only' // use default cache-first for refresh
|
||||
});
|
||||
}
|
||||
const sortAndSelectScope = (jobMetrics) =>
|
||||
metrics
|
||||
.map((name) => jobMetrics.filter((jobMetric) => jobMetric.name == name))
|
||||
.map((jobMetrics) => ({
|
||||
disabled: false,
|
||||
data: jobMetrics.length > 0 ? selectScope(jobMetrics) : null,
|
||||
}))
|
||||
.map((jobMetric) => {
|
||||
if (jobMetric.data) {
|
||||
return {
|
||||
disabled: checkMetricDisabled(
|
||||
jobMetric.data.name,
|
||||
job.cluster,
|
||||
job.subCluster,
|
||||
),
|
||||
data: jobMetric.data,
|
||||
};
|
||||
} else {
|
||||
return jobMetric;
|
||||
}
|
||||
});
|
||||
|
||||
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(name => jobMetrics.filter(jobMetric => jobMetric.name == name))
|
||||
.map(jobMetrics => ({ disabled: false, data: jobMetrics.length > 0 ? selectScope(jobMetrics) : null }))
|
||||
.map(jobMetric => {
|
||||
if (jobMetric.data) {
|
||||
return { disabled: checkMetricDisabled(jobMetric.data.name, job.cluster, job.subCluster), data: jobMetric.data }
|
||||
} else {
|
||||
return jobMetric
|
||||
}
|
||||
})
|
||||
|
||||
if (job.monitoringStatus) refresh();
|
||||
if (job.monitoringStatus) refresh();
|
||||
</script>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<JobInfo {job} />
|
||||
<td>
|
||||
<JobInfo {job} />
|
||||
</td>
|
||||
{#if job.monitoringStatus == 0 || job.monitoringStatus == 2}
|
||||
<td colspan={metrics.length}>
|
||||
<Card body color="warning">Not monitored or archiving failed</Card>
|
||||
</td>
|
||||
{#if job.monitoringStatus == 0 || job.monitoringStatus == 2}
|
||||
<td colspan={metrics.length}>
|
||||
<Card body color="warning">Not monitored or archiving failed</Card>
|
||||
</td>
|
||||
{:else if $metricsQuery.fetching}
|
||||
<td colspan={metrics.length} style="text-align: center;">
|
||||
<Spinner secondary />
|
||||
</td>
|
||||
{:else if $metricsQuery.error}
|
||||
<td colspan={metrics.length}>
|
||||
<Card body color="danger" class="mb-3">
|
||||
{$metricsQuery.error.message.length > 500
|
||||
? $metricsQuery.error.message.substring(0, 499) + "..."
|
||||
: $metricsQuery.error.message}
|
||||
</Card>
|
||||
</td>
|
||||
{:else}
|
||||
{#if showFootprint}
|
||||
<td>
|
||||
<JobFootprint
|
||||
job={job}
|
||||
jobMetrics={$metricsQuery.data.jobMetrics}
|
||||
width={plotWidth}
|
||||
view="list"
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
{#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric || i)}
|
||||
<td>
|
||||
<!-- Subluster Metricconfig remove keyword for jobtables (joblist main, user joblist, project joblist) to be used here as toplevel case-->
|
||||
{#if metric.disabled == false && metric.data}
|
||||
<MetricPlot
|
||||
width={plotWidth}
|
||||
height={plotHeight}
|
||||
timestep={metric.data.metric.timestep}
|
||||
scope={metric.data.scope}
|
||||
series={metric.data.metric.series}
|
||||
statisticsSeries={metric.data.metric.statisticsSeries}
|
||||
metric={metric.data.name}
|
||||
{cluster}
|
||||
subCluster={job.subCluster}
|
||||
isShared={(job.exclusive != 1)}
|
||||
resources={job.resources}
|
||||
hwthreads={job.numHWThreads}
|
||||
/>
|
||||
{:else if metric.disabled == true && metric.data}
|
||||
<Card body color="info">Metric disabled for subcluster <code>{metric.data.name}:{job.subCluster}</code></Card>
|
||||
{:else}
|
||||
<Card body color="warning">No dataset returned</Card>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{:else if $metricsQuery.fetching}
|
||||
<td colspan={metrics.length} style="text-align: center;">
|
||||
<Spinner secondary />
|
||||
</td>
|
||||
{:else if $metricsQuery.error}
|
||||
<td colspan={metrics.length}>
|
||||
<Card body color="danger" class="mb-3">
|
||||
{$metricsQuery.error.message.length > 500
|
||||
? $metricsQuery.error.message.substring(0, 499) + "..."
|
||||
: $metricsQuery.error.message}
|
||||
</Card>
|
||||
</td>
|
||||
{:else}
|
||||
{#if showFootprint}
|
||||
<td>
|
||||
<JobFootprint
|
||||
{job}
|
||||
jobMetrics={$metricsQuery.data.jobMetrics}
|
||||
width={plotWidth}
|
||||
view="list"
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
{#each sortAndSelectScope($metricsQuery.data.jobMetrics) as metric, i (metric || i)}
|
||||
<td>
|
||||
<!-- Subluster Metricconfig remove keyword for jobtables (joblist main, user joblist, project joblist) to be used here as toplevel case-->
|
||||
{#if metric.disabled == false && metric.data}
|
||||
<MetricPlot
|
||||
width={plotWidth}
|
||||
height={plotHeight}
|
||||
timestep={metric.data.metric.timestep}
|
||||
scope={metric.data.scope}
|
||||
series={metric.data.metric.series}
|
||||
statisticsSeries={metric.data.metric.statisticsSeries}
|
||||
metric={metric.data.name}
|
||||
{cluster}
|
||||
subCluster={job.subCluster}
|
||||
isShared={job.exclusive != 1}
|
||||
resources={job.resources}
|
||||
hwthreads={job.numHWThreads}
|
||||
/>
|
||||
{:else if metric.disabled == true && metric.data}
|
||||
<Card body color="info"
|
||||
>Metric disabled for subcluster <code
|
||||
>{metric.data.name}:{job.subCluster}</code
|
||||
></Card
|
||||
>
|
||||
{:else}
|
||||
<Card body color="warning">No dataset returned</Card>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{/if}
|
||||
</tr>
|
||||
|
@@ -7,65 +7,94 @@
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { Icon, Button, ListGroup, ListGroupItem,
|
||||
Modal, ModalBody, ModalHeader, ModalFooter } from 'sveltestrap'
|
||||
import {
|
||||
Icon,
|
||||
Button,
|
||||
ListGroup,
|
||||
ListGroupItem,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
export let isOpen = false
|
||||
export let sorting = { field: 'startTime', order: 'DESC' }
|
||||
export let isOpen = false;
|
||||
export let sorting = { field: "startTime", order: "DESC" };
|
||||
|
||||
let sortableColumns = [
|
||||
{ field: 'startTime', text: 'Start Time', order: 'DESC' },
|
||||
{ field: 'duration', text: 'Duration', order: 'DESC' },
|
||||
{ field: 'numNodes', text: 'Number of Nodes', order: 'DESC' },
|
||||
{ field: 'memUsedMax', text: 'Max. Memory Used', order: 'DESC' },
|
||||
{ field: 'flopsAnyAvg', text: 'Avg. FLOPs', order: 'DESC' },
|
||||
{ field: 'memBwAvg', text: 'Avg. Memory Bandwidth', order: 'DESC' },
|
||||
{ field: 'netBwAvg', text: 'Avg. Network Bandwidth', order: 'DESC' }
|
||||
]
|
||||
let sortableColumns = [
|
||||
{ field: "startTime", text: "Start Time", order: "DESC" },
|
||||
{ field: "duration", text: "Duration", order: "DESC" },
|
||||
{ field: "numNodes", text: "Number of Nodes", order: "DESC" },
|
||||
{ field: "memUsedMax", text: "Max. Memory Used", order: "DESC" },
|
||||
{ field: "flopsAnyAvg", text: "Avg. FLOPs", order: "DESC" },
|
||||
{ field: "memBwAvg", text: "Avg. Memory Bandwidth", order: "DESC" },
|
||||
{ field: "netBwAvg", text: "Avg. Network Bandwidth", order: "DESC" },
|
||||
];
|
||||
|
||||
let activeColumnIdx = sortableColumns.findIndex(col => col.field == sorting.field)
|
||||
sortableColumns[activeColumnIdx].order = sorting.order
|
||||
let activeColumnIdx = sortableColumns.findIndex(
|
||||
(col) => col.field == sorting.field,
|
||||
);
|
||||
sortableColumns[activeColumnIdx].order = sorting.order;
|
||||
</script>
|
||||
|
||||
<Modal isOpen={isOpen} toggle={() => { isOpen = !isOpen }}>
|
||||
<ModalHeader>
|
||||
Sort rows
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<ListGroup>
|
||||
{#each sortableColumns as col, i (col)}
|
||||
<ListGroupItem>
|
||||
<button class="sort" on:click={() => {
|
||||
if (activeColumnIdx == i) {
|
||||
col.order = col.order == 'DESC' ? 'ASC' : 'DESC'
|
||||
} else {
|
||||
sortableColumns[activeColumnIdx] = { ...sortableColumns[activeColumnIdx] }
|
||||
}
|
||||
<Modal
|
||||
{isOpen}
|
||||
toggle={() => {
|
||||
isOpen = !isOpen;
|
||||
}}
|
||||
>
|
||||
<ModalHeader>Sort rows</ModalHeader>
|
||||
<ModalBody>
|
||||
<ListGroup>
|
||||
{#each sortableColumns as col, i (col)}
|
||||
<ListGroupItem>
|
||||
<button
|
||||
class="sort"
|
||||
on:click={() => {
|
||||
if (activeColumnIdx == i) {
|
||||
col.order = col.order == "DESC" ? "ASC" : "DESC";
|
||||
} else {
|
||||
sortableColumns[activeColumnIdx] = {
|
||||
...sortableColumns[activeColumnIdx],
|
||||
};
|
||||
}
|
||||
|
||||
sortableColumns[i] = { ...sortableColumns[i] }
|
||||
activeColumnIdx = i
|
||||
sortableColumns = [...sortableColumns]
|
||||
sorting = { field: col.field, order: col.order }
|
||||
}}>
|
||||
<Icon name="arrow-{col.order == 'DESC' ? 'down' : 'up'}-circle{i == activeColumnIdx ? '-fill' : ''}"/>
|
||||
</button>
|
||||
sortableColumns[i] = { ...sortableColumns[i] };
|
||||
activeColumnIdx = i;
|
||||
sortableColumns = [...sortableColumns];
|
||||
sorting = { field: col.field, order: col.order };
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name="arrow-{col.order == 'DESC' ? 'down' : 'up'}-circle{i ==
|
||||
activeColumnIdx
|
||||
? '-fill'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{col.text}
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" on:click={() => { isOpen = false }}>Close</Button>
|
||||
</ModalFooter>
|
||||
{col.text}
|
||||
</ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
color="primary"
|
||||
on:click={() => {
|
||||
isOpen = false;
|
||||
}}>Close</Button
|
||||
>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.sort {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: 0 0;
|
||||
transition: all 70ms;
|
||||
}
|
||||
</style>
|
||||
.sort {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: 0 0;
|
||||
transition: all 70ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
Reference in New Issue
Block a user