mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-01-24 10:29:06 +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/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/tags/", "monitoring/taglist.tmpl", "Tags - ClusterCockpit", false, setupTaglistRoute},
|
||||||
{"/monitoring/user/{id}", "monitoring/user.tmpl", "User <ID> - ClusterCockpit", true, setupUserRoute},
|
{"/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/node/{cluster}/{hostname}", "monitoring/node.tmpl", "Node <ID> - ClusterCockpit", false, setupNodeRoute},
|
||||||
{"/monitoring/analysis/{cluster}", "monitoring/analysis.tmpl", "Analysis - ClusterCockpit", true, setupAnalysisRoute},
|
{"/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 {
|
func setupHomeRoute(i InfoType, r *http.Request) InfoType {
|
||||||
@ -96,7 +97,7 @@ func setupUserRoute(i InfoType, r *http.Request) InfoType {
|
|||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupClusterRoute(i InfoType, r *http.Request) InfoType {
|
func setupClusterStatusRoute(i InfoType, r *http.Request) InfoType {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
i["id"] = vars["cluster"]
|
i["id"] = vars["cluster"]
|
||||||
i["cluster"] = vars["cluster"]
|
i["cluster"] = vars["cluster"]
|
||||||
@ -108,6 +109,34 @@ func setupClusterRoute(i InfoType, r *http.Request) InfoType {
|
|||||||
return i
|
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 {
|
func setupNodeRoute(i InfoType, r *http.Request) InfoType {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
i["cluster"] = vars["cluster"]
|
i["cluster"] = vars["cluster"]
|
||||||
|
@ -97,7 +97,7 @@
|
|||||||
href: "/monitoring/systems/",
|
href: "/monitoring/systems/",
|
||||||
icon: "hdd-rack",
|
icon: "hdd-rack",
|
||||||
perCluster: true,
|
perCluster: true,
|
||||||
listOptions: false,
|
listOptions: true,
|
||||||
menu: "Info",
|
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:
|
Properties:
|
||||||
|
- `displayType String?`: The type of node display ['OVERVIEW' || 'LIST']
|
||||||
- `cluster String`: The cluster to show status information for
|
- `cluster String`: The cluster to show status information for
|
||||||
- `from Date?`: Custom Time Range selection 'from' [Default: null]
|
- `from Date?`: Custom Time Range selection 'from' [Default: null]
|
||||||
- `to Date?`: Custom Time Range selection 'to' [Default: null]
|
- `to Date?`: Custom Time Range selection 'to' [Default: null]
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getContext } from "svelte";
|
|
||||||
import {
|
import {
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputGroupText,
|
|
||||||
Icon,
|
|
||||||
Spinner,
|
|
||||||
Card,
|
Card,
|
||||||
} from "@sveltestrap/sveltestrap";
|
} 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 cluster;
|
||||||
export let from = null;
|
export let from = null;
|
||||||
export let to = null;
|
export let to = null;
|
||||||
|
|
||||||
const { query: initq } = init();
|
console.assert(
|
||||||
|
displayType == "OVERVIEW" || displayType == "LIST",
|
||||||
if (from == null || to == null) {
|
"Invalid nodes displayType provided!",
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<Row cols={{ xs: 2, lg: 4 }}>
|
{#if displayType === 'OVERVIEW'}
|
||||||
{#if $initq.error}
|
<NodeOverview {cluster} {from} {to}/>
|
||||||
<Card body color="danger">{$initq.error.message}</Card>
|
{:else if displayType === 'LIST'}
|
||||||
{:else if $initq.fetching}
|
<NodeList {cluster} {from} {to}/>
|
||||||
<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}
|
{:else}
|
||||||
<PlotGrid
|
<Row>
|
||||||
let:item
|
<Col>
|
||||||
renderFor="systems"
|
<Card color="danger">
|
||||||
itemsPerRow={ccconfig.plot_view_plotsPerRow}
|
Unknown displayList type!
|
||||||
items={$nodesQuery.data.nodeMetrics
|
</Card>
|
||||||
.filter(
|
</Col>
|
||||||
(h) =>
|
</Row>
|
||||||
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}
|
{/if}
|
||||||
|
@ -24,39 +24,69 @@
|
|||||||
|
|
||||||
{#each links as item}
|
{#each links as item}
|
||||||
{#if item.listOptions}
|
{#if item.listOptions}
|
||||||
<Dropdown nav inNavbar {direction}>
|
{#if item.title === 'Nodes'}
|
||||||
<DropdownToggle nav caret>
|
<Dropdown nav inNavbar {direction}>
|
||||||
<Icon name={item.icon} />
|
<DropdownToggle nav caret>
|
||||||
{item.title}
|
<Icon name={item.icon} />
|
||||||
</DropdownToggle>
|
{item.title}
|
||||||
<DropdownMenu class="dropdown-menu-lg-end">
|
</DropdownToggle>
|
||||||
<DropdownItem
|
<DropdownMenu class="dropdown-menu-lg-end">
|
||||||
href={item.href}
|
{#each clusters as cluster}
|
||||||
>
|
<Dropdown nav direction="right">
|
||||||
All Clusters
|
<DropdownToggle nav caret class="dropdown-item py-1 px-2">
|
||||||
</DropdownItem>
|
{cluster.name}
|
||||||
<DropdownItem divider />
|
</DropdownToggle>
|
||||||
{#each clusters as cluster}
|
<DropdownMenu>
|
||||||
<Dropdown nav direction="right">
|
<DropdownItem class="py-1 px-2"
|
||||||
<DropdownToggle nav caret class="dropdown-item py-1 px-2">
|
href={item.href + cluster.name}
|
||||||
{cluster.name}
|
>
|
||||||
</DropdownToggle>
|
Node Overview
|
||||||
<DropdownMenu>
|
</DropdownItem>
|
||||||
<DropdownItem class="py-1 px-2"
|
<DropdownItem class="py-1 px-2"
|
||||||
href={item.href + '?cluster=' + cluster.name}
|
href={item.href + 'list/' + cluster.name}
|
||||||
>
|
>
|
||||||
All Jobs
|
Node List
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem class="py-1 px-2"
|
</DropdownMenu>
|
||||||
href={item.href + '?cluster=' + cluster.name + '&state=running'}
|
</Dropdown>
|
||||||
>
|
{/each}
|
||||||
Running Jobs
|
</DropdownMenu>
|
||||||
</DropdownItem>
|
</Dropdown>
|
||||||
</DropdownMenu>
|
{:else}
|
||||||
</Dropdown>
|
<Dropdown nav inNavbar {direction}>
|
||||||
{/each}
|
<DropdownToggle nav caret>
|
||||||
</DropdownMenu>
|
<Icon name={item.icon} />
|
||||||
</Dropdown>
|
{item.title}
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu class="dropdown-menu-lg-end">
|
||||||
|
<DropdownItem
|
||||||
|
href={item.href}
|
||||||
|
>
|
||||||
|
All Clusters
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem divider />
|
||||||
|
{#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=' + cluster.name}
|
||||||
|
>
|
||||||
|
All Jobs
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem class="py-1 px-2"
|
||||||
|
href={item.href + '?cluster=' + cluster.name + '&state=running'}
|
||||||
|
>
|
||||||
|
Running Jobs
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
{/if}
|
||||||
{:else if !item.perCluster}
|
{:else if !item.perCluster}
|
||||||
<NavLink href={item.href} active={window.location.pathname == item.href}
|
<NavLink href={item.href} active={window.location.pathname == item.href}
|
||||||
><Icon name={item.icon} /> {item.title}</NavLink
|
><Icon name={item.icon} /> {item.title}</NavLink
|
||||||
|
@ -4,6 +4,7 @@ import Systems from './Systems.root.svelte'
|
|||||||
new Systems({
|
new Systems({
|
||||||
target: document.getElementById('svelte-app'),
|
target: document.getElementById('svelte-app'),
|
||||||
props: {
|
props: {
|
||||||
|
displayType: displayType,
|
||||||
cluster: infos.cluster,
|
cluster: infos.cluster,
|
||||||
from: infos.from,
|
from: infos.from,
|
||||||
to: infos.to
|
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}}
|
{{end}}
|
||||||
{{define "javascript"}}
|
{{define "javascript"}}
|
||||||
<script>
|
<script>
|
||||||
|
const displayType = {{ .Infos.displayType }};
|
||||||
const infos = {{ .Infos }};
|
const infos = {{ .Infos }};
|
||||||
const clusterCockpitConfig = {{ .Config }};
|
const clusterCockpitConfig = {{ .Config }};
|
||||||
</script>
|
</script>
|
||||||
|
Loading…
Reference in New Issue
Block a user