mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-01-24 02:19:05 +01:00
Split systems view into node-overview and node-list
This commit is contained in:
parent
2f0460d6ec
commit
2cbe8e9517
@ -42,10 +42,11 @@ var routes []Route = []Route{
|
||||
{"/monitoring/projects/", "monitoring/list.tmpl", "Projects - ClusterCockpit", true, func(i InfoType, r *http.Request) InfoType { i["listType"] = "PROJECT"; return i }},
|
||||
{"/monitoring/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute},
|
||||
{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute},
|
||||
{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> - ClusterCockpit", false, setupClusterRoute},
|
||||
{"/monitoring/systems/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> Overview - ClusterCockpit", false, setupClusterOverviewRoute},
|
||||
{"/monitoring/systems/list/{cluster}", "monitoring/systems.tmpl", "Cluster <ID> List - ClusterCockpit", false, setupClusterListRoute},
|
||||
{"/monitoring/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute},
|
||||
{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute},
|
||||
{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterRoute},
|
||||
{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterStatusRoute},
|
||||
}
|
||||
|
||||
func setupHomeRoute(i InfoType, r *http.Request) InfoType {
|
||||
@ -96,7 +97,7 @@ func setupUserRoute(i InfoType, r *http.Request) InfoType {
|
||||
return i
|
||||
}
|
||||
|
||||
func setupClusterRoute(i InfoType, r *http.Request) InfoType {
|
||||
func setupClusterStatusRoute(i InfoType, r *http.Request) InfoType {
|
||||
vars := mux.Vars(r)
|
||||
i["id"] = vars["cluster"]
|
||||
i["cluster"] = vars["cluster"]
|
||||
@ -108,6 +109,34 @@ func setupClusterRoute(i InfoType, r *http.Request) InfoType {
|
||||
return i
|
||||
}
|
||||
|
||||
func setupClusterOverviewRoute(i InfoType, r *http.Request) InfoType {
|
||||
vars := mux.Vars(r)
|
||||
i["id"] = vars["cluster"]
|
||||
i["cluster"] = vars["cluster"]
|
||||
i["displayType"] = "OVERVIEW"
|
||||
|
||||
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
|
||||
if from != "" || to != "" {
|
||||
i["from"] = from
|
||||
i["to"] = to
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func setupClusterListRoute(i InfoType, r *http.Request) InfoType {
|
||||
vars := mux.Vars(r)
|
||||
i["id"] = vars["cluster"]
|
||||
i["cluster"] = vars["cluster"]
|
||||
i["displayType"] = "LIST"
|
||||
|
||||
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
|
||||
if from != "" || to != "" {
|
||||
i["from"] = from
|
||||
i["to"] = to
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func setupNodeRoute(i InfoType, r *http.Request) InfoType {
|
||||
vars := mux.Vars(r)
|
||||
i["cluster"] = vars["cluster"]
|
||||
|
@ -97,7 +97,7 @@
|
||||
href: "/monitoring/systems/",
|
||||
icon: "hdd-rack",
|
||||
perCluster: true,
|
||||
listOptions: false,
|
||||
listOptions: true,
|
||||
menu: "Info",
|
||||
},
|
||||
{
|
||||
|
@ -1,232 +1,44 @@
|
||||
<!--
|
||||
@component Main cluster metric status view component; renders current state of metrics / nodes
|
||||
@component Main cluster node status view component; renders overview or list depending on type
|
||||
|
||||
Properties:
|
||||
- `displayType String?`: The type of node display ['OVERVIEW' || 'LIST']
|
||||
- `cluster String`: The cluster to show status information for
|
||||
- `from Date?`: Custom Time Range selection 'from' [Default: null]
|
||||
- `to Date?`: Custom Time Range selection 'to' [Default: null]
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Icon,
|
||||
Spinner,
|
||||
Card,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
getContextClient,
|
||||
} from "@urql/svelte";
|
||||
import {
|
||||
init,
|
||||
checkMetricDisabled,
|
||||
} from "./generic/utils.js";
|
||||
import PlotGrid from "./generic/PlotGrid.svelte";
|
||||
import MetricPlot from "./generic/plots/MetricPlot.svelte";
|
||||
import TimeSelection from "./generic/select/TimeSelection.svelte";
|
||||
import Refresher from "./generic/helper/Refresher.svelte";
|
||||
|
||||
import NodeOverview from "./systems/NodeOverview.svelte";
|
||||
import NodeList from "./systems/NodeList.svelte";
|
||||
|
||||
export let displayType;
|
||||
export let cluster;
|
||||
export let from = null;
|
||||
export let to = null;
|
||||
|
||||
const { query: initq } = init();
|
||||
|
||||
if (from == null || to == null) {
|
||||
to = new Date(Date.now());
|
||||
from = new Date(to.getTime());
|
||||
from.setHours(from.getHours() - 12);
|
||||
}
|
||||
|
||||
const initialized = getContext("initialized");
|
||||
const ccconfig = getContext("cc-config");
|
||||
const clusters = getContext("clusters");
|
||||
const globalMetrics = getContext("globalMetrics");
|
||||
|
||||
let hostnameFilter = "";
|
||||
let selectedMetric = ccconfig.system_view_selectedMetric;
|
||||
|
||||
const client = getContextClient();
|
||||
$: nodesQuery = queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query ($cluster: String!, $metrics: [String!], $from: Time!, $to: Time!) {
|
||||
nodeMetrics(
|
||||
cluster: $cluster
|
||||
metrics: $metrics
|
||||
from: $from
|
||||
to: $to
|
||||
) {
|
||||
host
|
||||
subCluster
|
||||
metrics {
|
||||
name
|
||||
scope
|
||||
metric {
|
||||
timestep
|
||||
unit {
|
||||
base
|
||||
prefix
|
||||
}
|
||||
series {
|
||||
statistics {
|
||||
min
|
||||
avg
|
||||
max
|
||||
}
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
cluster: cluster,
|
||||
metrics: [selectedMetric],
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
let systemMetrics = [];
|
||||
let systemUnits = {};
|
||||
function loadMetrics(isInitialized) {
|
||||
if (!isInitialized) return
|
||||
systemMetrics = [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))]
|
||||
for (let sm of systemMetrics) {
|
||||
systemUnits[sm.name] = (sm?.unit?.prefix ? sm.unit.prefix : "") + (sm?.unit?.base ? sm.unit.base : "")
|
||||
}
|
||||
}
|
||||
|
||||
$: loadMetrics($initialized)
|
||||
|
||||
console.assert(
|
||||
displayType == "OVERVIEW" || displayType == "LIST",
|
||||
"Invalid nodes displayType provided!",
|
||||
);
|
||||
</script>
|
||||
|
||||
<Row cols={{ xs: 2, lg: 4 }}>
|
||||
{#if $initq.error}
|
||||
<Card body color="danger">{$initq.error.message}</Card>
|
||||
{:else if $initq.fetching}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<!-- Node Col-->
|
||||
<Col>
|
||||
<InputGroup>
|
||||
<InputGroupText><Icon name="hdd" /></InputGroupText>
|
||||
<InputGroupText>Find Node</InputGroupText>
|
||||
<Input
|
||||
placeholder="hostname..."
|
||||
type="text"
|
||||
bind:value={hostnameFilter}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<!-- Range Col-->
|
||||
<Col>
|
||||
<TimeSelection bind:from bind:to />
|
||||
</Col>
|
||||
<!-- Metric Col-->
|
||||
<Col class="mt-2 mt-lg-0">
|
||||
<InputGroup>
|
||||
<InputGroupText><Icon name="graph-up" /></InputGroupText>
|
||||
<InputGroupText>Metric</InputGroupText>
|
||||
<select class="form-select" bind:value={selectedMetric}>
|
||||
{#each systemMetrics as metric}
|
||||
<option value={metric.name}
|
||||
>{metric.name} {systemUnits[metric.name] ? "("+systemUnits[metric.name]+")" : ""}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<!-- Refresh Col-->
|
||||
<Col class="mt-2 mt-lg-0">
|
||||
<Refresher
|
||||
on:refresh={() => {
|
||||
const diff = Date.now() - to;
|
||||
from = new Date(from.getTime() + diff);
|
||||
to = new Date(to.getTime() + diff);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
<br />
|
||||
{#if $nodesQuery.error}
|
||||
<Row>
|
||||
<Col>
|
||||
<Card body color="danger">{$nodesQuery.error.message}</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{:else if $nodesQuery.fetching || $initq.fetching}
|
||||
<Row>
|
||||
<Col>
|
||||
<Spinner />
|
||||
</Col>
|
||||
</Row>
|
||||
{#if displayType === 'OVERVIEW'}
|
||||
<NodeOverview {cluster} {from} {to}/>
|
||||
{:else if displayType === 'LIST'}
|
||||
<NodeList {cluster} {from} {to}/>
|
||||
{:else}
|
||||
<PlotGrid
|
||||
let:item
|
||||
renderFor="systems"
|
||||
itemsPerRow={ccconfig.plot_view_plotsPerRow}
|
||||
items={$nodesQuery.data.nodeMetrics
|
||||
.filter(
|
||||
(h) =>
|
||||
h.host.includes(hostnameFilter) &&
|
||||
h.metrics.some(
|
||||
(m) => m.name == selectedMetric && m.scope == "node",
|
||||
),
|
||||
)
|
||||
.map((h) => ({
|
||||
host: h.host,
|
||||
subCluster: h.subCluster,
|
||||
data: h.metrics.find(
|
||||
(m) => m.name == selectedMetric && m.scope == "node",
|
||||
),
|
||||
disabled: checkMetricDisabled(
|
||||
selectedMetric,
|
||||
cluster,
|
||||
h.subCluster,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.host.localeCompare(b.host))}
|
||||
>
|
||||
<h4 style="width: 100%; text-align: center;">
|
||||
<a
|
||||
style="display: block;padding-top: 15px;"
|
||||
href="/monitoring/node/{cluster}/{item.host}"
|
||||
>{item.host} ({item.subCluster})</a
|
||||
>
|
||||
</h4>
|
||||
{#if item.disabled === false && item.data}
|
||||
<MetricPlot
|
||||
timestep={item.data.metric.timestep}
|
||||
series={item.data.metric.series}
|
||||
metric={item.data.name}
|
||||
cluster={clusters.find((c) => c.name == cluster)}
|
||||
subCluster={item.subCluster}
|
||||
forNode={true}
|
||||
/>
|
||||
{:else if item.disabled === true && item.data}
|
||||
<Card style="margin-left: 2rem;margin-right: 2rem;" body color="info"
|
||||
>Metric disabled for subcluster <code
|
||||
>{selectedMetric}:{item.subCluster}</code
|
||||
></Card
|
||||
>
|
||||
{:else}
|
||||
<Card
|
||||
style="margin-left: 2rem;margin-right: 2rem;"
|
||||
body
|
||||
color="warning"
|
||||
>No dataset returned for <code>{selectedMetric}</code></Card
|
||||
>
|
||||
{/if}
|
||||
</PlotGrid>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card color="danger">
|
||||
Unknown displayList type!
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
|
@ -24,6 +24,35 @@
|
||||
|
||||
{#each links as item}
|
||||
{#if item.listOptions}
|
||||
{#if item.title === 'Nodes'}
|
||||
<Dropdown nav inNavbar {direction}>
|
||||
<DropdownToggle nav caret>
|
||||
<Icon name={item.icon} />
|
||||
{item.title}
|
||||
</DropdownToggle>
|
||||
<DropdownMenu class="dropdown-menu-lg-end">
|
||||
{#each clusters as cluster}
|
||||
<Dropdown nav direction="right">
|
||||
<DropdownToggle nav caret class="dropdown-item py-1 px-2">
|
||||
{cluster.name}
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
<DropdownItem class="py-1 px-2"
|
||||
href={item.href + cluster.name}
|
||||
>
|
||||
Node Overview
|
||||
</DropdownItem>
|
||||
<DropdownItem class="py-1 px-2"
|
||||
href={item.href + 'list/' + cluster.name}
|
||||
>
|
||||
Node List
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
{/each}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
{:else}
|
||||
<Dropdown nav inNavbar {direction}>
|
||||
<DropdownToggle nav caret>
|
||||
<Icon name={item.icon} />
|
||||
@ -57,6 +86,7 @@
|
||||
{/each}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
{/if}
|
||||
{:else if !item.perCluster}
|
||||
<NavLink href={item.href} active={window.location.pathname == item.href}
|
||||
><Icon name={item.icon} /> {item.title}</NavLink
|
||||
|
@ -4,6 +4,7 @@ import Systems from './Systems.root.svelte'
|
||||
new Systems({
|
||||
target: document.getElementById('svelte-app'),
|
||||
props: {
|
||||
displayType: displayType,
|
||||
cluster: infos.cluster,
|
||||
from: infos.from,
|
||||
to: infos.to
|
||||
|
322
web/frontend/src/systems/NodeList.svelte
Normal file
322
web/frontend/src/systems/NodeList.svelte
Normal file
@ -0,0 +1,322 @@
|
||||
<!--
|
||||
@component Main jobList component; lists jobs according to set filters
|
||||
|
||||
Properties:
|
||||
- `sorting Object?`: Currently active sorting [Default: {field: "startTime", type: "col", order: "DESC"}]
|
||||
- `matchedJobs Number?`: Number of matched jobs for selected filters [Default: 0]
|
||||
- `metrics [String]?`: The currently selected metrics [Default: User-Configured Selection]
|
||||
- `showFootprint Bool`: If to display the jobFootprint component
|
||||
|
||||
Functions:
|
||||
- `refreshJobs()`: Load jobs data with unchanged parameters and 'network-only' keyword
|
||||
- `refreshAllMetrics()`: Trigger downstream refresh of all running jobs' metric data
|
||||
- `queryJobs(filters?: [JobFilter])`: Load jobs data with new filters, starts from page 1
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
getContextClient,
|
||||
mutationStore,
|
||||
} from "@urql/svelte";
|
||||
import { Row, Table, Card, Spinner } from "@sveltestrap/sveltestrap";
|
||||
import { stickyHeader } from "../generic/utils.js";
|
||||
import Pagination from "../generic/joblist/Pagination.svelte";
|
||||
import JobListRow from "../generic/joblist/JobListRow.svelte";
|
||||
|
||||
const ccconfig = getContext("cc-config"),
|
||||
initialized = getContext("initialized"),
|
||||
globalMetrics = getContext("globalMetrics");
|
||||
|
||||
export let sorting = { field: "startTime", type: "col", order: "DESC" };
|
||||
export let matchedJobs = 0;
|
||||
export let metrics = ccconfig.plot_list_selectedMetrics;
|
||||
export let showFootprint;
|
||||
|
||||
let usePaging = ccconfig.job_list_usePaging
|
||||
let itemsPerPage = usePaging ? ccconfig.plot_list_jobsPerPage : 10;
|
||||
let page = 1;
|
||||
let paging = { itemsPerPage, page };
|
||||
let filter = [];
|
||||
let triggerMetricRefresh = false;
|
||||
|
||||
function getUnit(m) {
|
||||
const rawUnit = globalMetrics.find((gm) => gm.name === m)?.unit
|
||||
return (rawUnit?.prefix ? rawUnit.prefix : "") + (rawUnit?.base ? rawUnit.base : "")
|
||||
}
|
||||
|
||||
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
|
||||
scope
|
||||
}
|
||||
userData {
|
||||
name
|
||||
}
|
||||
metaData
|
||||
footprint {
|
||||
name
|
||||
stat
|
||||
value
|
||||
}
|
||||
}
|
||||
count
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
$: jobsStore = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { paging, sorting, filter },
|
||||
});
|
||||
|
||||
let jobs = []
|
||||
$: if ($initialized && $jobsStore.data) {
|
||||
jobs = [...$jobsStore.data.jobs.items]
|
||||
}
|
||||
|
||||
$: matchedJobs = $jobsStore.data != null ? $jobsStore.data.jobs.count : -1;
|
||||
|
||||
// Force refresh list with existing unchanged variables (== usually would not trigger reactivity)
|
||||
export function refreshJobs() {
|
||||
jobsStore = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { paging, sorting, filter },
|
||||
requestPolicy: "network-only",
|
||||
});
|
||||
}
|
||||
|
||||
export function refreshAllMetrics() {
|
||||
// Refresh Job Metrics (Downstream will only query for running jobs)
|
||||
triggerMetricRefresh = true
|
||||
setTimeout(function () {
|
||||
triggerMetricRefresh = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// (Re-)query and optionally set new filters; Query will be started reactively.
|
||||
export function queryJobs(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 };
|
||||
}
|
||||
|
||||
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) {
|
||||
jobs = [] // Empty List
|
||||
paging = { itemsPerPage: value, page: page }; // Trigger reload of jobList
|
||||
} else if (res.fetching === false && res.error) {
|
||||
throw res.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!usePaging) {
|
||||
let scrollMultiplier = 1
|
||||
window.addEventListener('scroll', () => {
|
||||
let {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight
|
||||
} = document.documentElement;
|
||||
|
||||
// Add 100 px offset to trigger load earlier
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100 && $jobsStore.data != null && $jobsStore.data.jobs.hasNextPage) {
|
||||
let pendingPaging = { ...paging }
|
||||
scrollMultiplier += 1
|
||||
pendingPaging.itemsPerPage = itemsPerPage * scrollMultiplier
|
||||
paging = pendingPaging
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
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),
|
||||
);
|
||||
</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}
|
||||
({getUnit(metric)})
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $jobsStore.error}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}>
|
||||
<Card body color="danger" class="mb-3"
|
||||
><h2>{$jobsStore.error.message}</h2></Card
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each jobs as job (job)}
|
||||
<JobListRow bind:triggerMetricRefresh {job} {metrics} {plotWidth} {showFootprint} />
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}> No jobs found </td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if $jobsStore.fetching || !$jobsStore.data}
|
||||
<tr>
|
||||
<td colspan={metrics.length + 1}>
|
||||
<div style="text-align:center;">
|
||||
<Spinner secondary />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{#if usePaging}
|
||||
<Pagination
|
||||
bind:page
|
||||
{itemsPerPage}
|
||||
itemText="Jobs"
|
||||
totalItems={matchedJobs}
|
||||
on:update-paging={({ detail }) => {
|
||||
if (detail.itemsPerPage != itemsPerPage) {
|
||||
updateConfiguration(detail.itemsPerPage.toString(), detail.page);
|
||||
} else {
|
||||
jobs = []
|
||||
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page };
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.cc-table-wrapper {
|
||||
overflow: initial;
|
||||
}
|
||||
|
||||
.cc-table-wrapper > :global(table) {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.cc-table-wrapper :global(button) {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.cc-table-wrapper > :global(table > tbody > tr > td) {
|
||||
margin: 0px;
|
||||
padding-left: 5px;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
th.position-sticky.top-0 {
|
||||
background-color: white;
|
||||
z-index: 10;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
</style>
|
232
web/frontend/src/systems/NodeOverview.svelte
Normal file
232
web/frontend/src/systems/NodeOverview.svelte
Normal file
@ -0,0 +1,232 @@
|
||||
<!--
|
||||
@component Cluster Per Node Overview component; renders current state of ONE metric for ALL nodes
|
||||
|
||||
Properties:
|
||||
- `cluster String`: The cluster to show status information for
|
||||
- `from Date?`: Custom Time Range selection 'from' [Default: null]
|
||||
- `to Date?`: Custom Time Range selection 'to' [Default: null]
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Icon,
|
||||
Spinner,
|
||||
Card,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import {
|
||||
queryStore,
|
||||
gql,
|
||||
getContextClient,
|
||||
} from "@urql/svelte";
|
||||
import {
|
||||
init,
|
||||
checkMetricDisabled,
|
||||
} from "../generic/utils.js";
|
||||
import PlotGrid from "../generic/PlotGrid.svelte";
|
||||
import MetricPlot from "../generic/plots/MetricPlot.svelte";
|
||||
import TimeSelection from "../generic/select/TimeSelection.svelte";
|
||||
import Refresher from "../generic/helper/Refresher.svelte";
|
||||
|
||||
export let cluster;
|
||||
export let from = null;
|
||||
export let to = null;
|
||||
|
||||
const { query: initq } = init();
|
||||
|
||||
if (from == null || to == null) {
|
||||
to = new Date(Date.now());
|
||||
from = new Date(to.getTime());
|
||||
from.setHours(from.getHours() - 12);
|
||||
}
|
||||
|
||||
const initialized = getContext("initialized");
|
||||
const ccconfig = getContext("cc-config");
|
||||
const clusters = getContext("clusters");
|
||||
const globalMetrics = getContext("globalMetrics");
|
||||
|
||||
let hostnameFilter = "";
|
||||
let selectedMetric = ccconfig.system_view_selectedMetric;
|
||||
|
||||
const client = getContextClient();
|
||||
$: nodesQuery = queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query ($cluster: String!, $metrics: [String!], $from: Time!, $to: Time!) {
|
||||
nodeMetrics(
|
||||
cluster: $cluster
|
||||
metrics: $metrics
|
||||
from: $from
|
||||
to: $to
|
||||
) {
|
||||
host
|
||||
subCluster
|
||||
metrics {
|
||||
name
|
||||
scope
|
||||
metric {
|
||||
timestep
|
||||
unit {
|
||||
base
|
||||
prefix
|
||||
}
|
||||
series {
|
||||
statistics {
|
||||
min
|
||||
avg
|
||||
max
|
||||
}
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
cluster: cluster,
|
||||
metrics: [selectedMetric],
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
let systemMetrics = [];
|
||||
let systemUnits = {};
|
||||
function loadMetrics(isInitialized) {
|
||||
if (!isInitialized) return
|
||||
systemMetrics = [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))]
|
||||
for (let sm of systemMetrics) {
|
||||
systemUnits[sm.name] = (sm?.unit?.prefix ? sm.unit.prefix : "") + (sm?.unit?.base ? sm.unit.base : "")
|
||||
}
|
||||
}
|
||||
|
||||
$: loadMetrics($initialized)
|
||||
|
||||
</script>
|
||||
|
||||
<Row cols={{ xs: 2, lg: 4 }}>
|
||||
{#if $initq.error}
|
||||
<Card body color="danger">{$initq.error.message}</Card>
|
||||
{:else if $initq.fetching}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<!-- Node Col-->
|
||||
<Col>
|
||||
<InputGroup>
|
||||
<InputGroupText><Icon name="hdd" /></InputGroupText>
|
||||
<InputGroupText>Find Node</InputGroupText>
|
||||
<Input
|
||||
placeholder="hostname..."
|
||||
type="text"
|
||||
bind:value={hostnameFilter}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<!-- Range Col-->
|
||||
<Col>
|
||||
<TimeSelection bind:from bind:to />
|
||||
</Col>
|
||||
<!-- Metric Col-->
|
||||
<Col class="mt-2 mt-lg-0">
|
||||
<InputGroup>
|
||||
<InputGroupText><Icon name="graph-up" /></InputGroupText>
|
||||
<InputGroupText>Metric</InputGroupText>
|
||||
<select class="form-select" bind:value={selectedMetric}>
|
||||
{#each systemMetrics as metric}
|
||||
<option value={metric.name}
|
||||
>{metric.name} {systemUnits[metric.name] ? "("+systemUnits[metric.name]+")" : ""}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<!-- Refresh Col-->
|
||||
<Col class="mt-2 mt-lg-0">
|
||||
<Refresher
|
||||
on:refresh={() => {
|
||||
const diff = Date.now() - to;
|
||||
from = new Date(from.getTime() + diff);
|
||||
to = new Date(to.getTime() + diff);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
<br />
|
||||
{#if $nodesQuery.error}
|
||||
<Row>
|
||||
<Col>
|
||||
<Card body color="danger">{$nodesQuery.error.message}</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{:else if $nodesQuery.fetching || $initq.fetching}
|
||||
<Row>
|
||||
<Col>
|
||||
<Spinner />
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
<PlotGrid
|
||||
let:item
|
||||
renderFor="systems"
|
||||
itemsPerRow={ccconfig.plot_view_plotsPerRow}
|
||||
items={$nodesQuery.data.nodeMetrics
|
||||
.filter(
|
||||
(h) =>
|
||||
h.host.includes(hostnameFilter) &&
|
||||
h.metrics.some(
|
||||
(m) => m.name == selectedMetric && m.scope == "node",
|
||||
),
|
||||
)
|
||||
.map((h) => ({
|
||||
host: h.host,
|
||||
subCluster: h.subCluster,
|
||||
data: h.metrics.find(
|
||||
(m) => m.name == selectedMetric && m.scope == "node",
|
||||
),
|
||||
disabled: checkMetricDisabled(
|
||||
selectedMetric,
|
||||
cluster,
|
||||
h.subCluster,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.host.localeCompare(b.host))}
|
||||
>
|
||||
<h4 style="width: 100%; text-align: center;">
|
||||
<a
|
||||
style="display: block;padding-top: 15px;"
|
||||
href="/monitoring/node/{cluster}/{item.host}"
|
||||
>{item.host} ({item.subCluster})</a
|
||||
>
|
||||
</h4>
|
||||
{#if item.disabled === false && item.data}
|
||||
<MetricPlot
|
||||
timestep={item.data.metric.timestep}
|
||||
series={item.data.metric.series}
|
||||
metric={item.data.name}
|
||||
cluster={clusters.find((c) => c.name == cluster)}
|
||||
subCluster={item.subCluster}
|
||||
forNode={true}
|
||||
/>
|
||||
{:else if item.disabled === true && item.data}
|
||||
<Card style="margin-left: 2rem;margin-right: 2rem;" body color="info"
|
||||
>Metric disabled for subcluster <code
|
||||
>{selectedMetric}:{item.subCluster}</code
|
||||
></Card
|
||||
>
|
||||
{:else}
|
||||
<Card
|
||||
style="margin-left: 2rem;margin-right: 2rem;"
|
||||
body
|
||||
color="warning"
|
||||
>No dataset returned for <code>{selectedMetric}</code></Card
|
||||
>
|
||||
{/if}
|
||||
</PlotGrid>
|
||||
{/if}
|
152
web/frontend/src/systems/nodelist/NodeInfo.svelte
Normal file
152
web/frontend/src/systems/nodelist/NodeInfo.svelte
Normal file
@ -0,0 +1,152 @@
|
||||
<!--
|
||||
@component Displays job metaData, serves links to detail pages
|
||||
|
||||
Properties:
|
||||
- `job Object`: The Job Object (GraphQL.Job)
|
||||
- `jobTags [Number]?`: The jobs tags as IDs, default useful for dynamically updating the tags [Default: job.tags]
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { Badge, Icon } from "@sveltestrap/sveltestrap";
|
||||
import { scrambleNames, scramble } from "../../generic/utils.js";
|
||||
import Tag from "../../generic/helper/Tag.svelte";
|
||||
import TagManagement from "../../generic/helper/TagManagement.svelte";
|
||||
|
||||
export let job;
|
||||
export let jobTags = job.tags;
|
||||
export let showTagedit = false;
|
||||
export let username = null;
|
||||
export let authlevel= null;
|
||||
export let roles = null;
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p class="mb-2">
|
||||
<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 class="mb-2">
|
||||
<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 class="mb-2">
|
||||
{#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 class="mb-2">
|
||||
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>
|
||||
|
||||
{#if showTagedit}
|
||||
<hr class="mt-0 mb-2"/>
|
||||
<p class="mb-1">
|
||||
<TagManagement bind:jobTags {job} {username} {authlevel} {roles} renderModal/> :
|
||||
{#if jobTags?.length > 0}
|
||||
{#each jobTags as tag}
|
||||
<Tag {tag}/>
|
||||
{/each}
|
||||
{:else}
|
||||
<span style="font-size: 0.9rem; background-color: lightgray;" class="my-1 badge text-dark">No Tags</span>
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mb-1">
|
||||
{#each jobTags as tag}
|
||||
<Tag {tag} />
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
207
web/frontend/src/systems/nodelist/NodeListRow.svelte
Normal file
207
web/frontend/src/systems/nodelist/NodeListRow.svelte
Normal file
@ -0,0 +1,207 @@
|
||||
<!--
|
||||
@component Data row for a single job displaying metric plots
|
||||
|
||||
Properties:
|
||||
- `job Object`: The job object (GraphQL.Job)
|
||||
- `metrics [String]`: Currently selected metrics
|
||||
- `plotWidth Number`: Width of the sub-components
|
||||
- `plotHeight Number?`: Height of the sub-components [Default: 275]
|
||||
- `showFootprint Bool`: Display of footprint component for job
|
||||
- `triggerMetricRefresh Bool?`: If changed to true from upstream, will trigger metric query
|
||||
-->
|
||||
|
||||
<script>
|
||||
import { queryStore, gql, getContextClient } from "@urql/svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { Card, Spinner } from "@sveltestrap/sveltestrap";
|
||||
import { maxScope, checkMetricDisabled } from "../../generic/utils.js";
|
||||
import JobInfo from "./NodeInfo.svelte";
|
||||
import MetricPlot from "../../generic/plots/MetricPlot.svelte";
|
||||
import JobFootprint from "../../generic/helper/JobFootprint.svelte";
|
||||
|
||||
export let job;
|
||||
export let metrics;
|
||||
export let plotWidth;
|
||||
export let plotHeight = 275;
|
||||
export let showFootprint;
|
||||
export let triggerMetricRefresh = false;
|
||||
|
||||
const resampleConfig = getContext("resampling") || null;
|
||||
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
|
||||
|
||||
let { id } = job;
|
||||
let scopes = job.numNodes == 1
|
||||
? job.numAcc >= 1
|
||||
? ["core", "accelerator"]
|
||||
: ["core"]
|
||||
: ["node"];
|
||||
let selectedResolution = resampleDefault;
|
||||
let zoomStates = {};
|
||||
|
||||
const cluster = getContext("clusters").find((c) => c.name == job.cluster);
|
||||
const client = getContextClient();
|
||||
const query = gql`
|
||||
query ($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!, $selectedResolution: Int) {
|
||||
jobMetrics(id: $id, metrics: $metrics, scopes: $scopes, resolution: $selectedResolution) {
|
||||
name
|
||||
scope
|
||||
metric {
|
||||
unit {
|
||||
prefix
|
||||
base
|
||||
}
|
||||
timestep
|
||||
statisticsSeries {
|
||||
min
|
||||
mean
|
||||
median
|
||||
max
|
||||
}
|
||||
series {
|
||||
hostname
|
||||
id
|
||||
data
|
||||
statistics {
|
||||
min
|
||||
avg
|
||||
max
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function handleZoom(detail, metric) {
|
||||
if ( // States have to differ, causes deathloop if just set
|
||||
(zoomStates[metric]?.x?.min !== detail?.lastZoomState?.x?.min) &&
|
||||
(zoomStates[metric]?.y?.max !== detail?.lastZoomState?.y?.max)
|
||||
) {
|
||||
zoomStates[metric] = {...detail.lastZoomState}
|
||||
}
|
||||
|
||||
if (detail?.newRes) { // Triggers GQL
|
||||
selectedResolution = detail.newRes
|
||||
}
|
||||
}
|
||||
|
||||
$: metricsQuery = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { id, metrics, scopes, selectedResolution },
|
||||
});
|
||||
|
||||
function refreshMetrics() {
|
||||
metricsQuery = queryStore({
|
||||
client: client,
|
||||
query: query,
|
||||
variables: { id, metrics, scopes, selectedResolution },
|
||||
// requestPolicy: 'network-only' // use default cache-first for refresh
|
||||
});
|
||||
}
|
||||
|
||||
$: if (job.state === 'running' && triggerMetricRefresh === true) {
|
||||
refreshMetrics();
|
||||
}
|
||||
|
||||
// Helper
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<tr>
|
||||
<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>
|
||||
{: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}
|
||||
width={plotWidth}
|
||||
height="{plotHeight}px"
|
||||
displayTitle={false}
|
||||
/>
|
||||
</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
|
||||
on:zoom={({detail}) => { handleZoom(detail, metric.data.name) }}
|
||||
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}
|
||||
numhwthreads={job.numHWThreads}
|
||||
numaccs={job.numAcc}
|
||||
zoomState={zoomStates[metric.data.name] || null}
|
||||
/>
|
||||
{: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,6 +7,7 @@
|
||||
{{end}}
|
||||
{{define "javascript"}}
|
||||
<script>
|
||||
const displayType = {{ .Infos.displayType }};
|
||||
const infos = {{ .Infos }};
|
||||
const clusterCockpitConfig = {{ .Config }};
|
||||
</script>
|
||||
|
Loading…
Reference in New Issue
Block a user