migrate system view, node list and node overview

This commit is contained in:
Christoph Kluge 2025-06-12 16:23:31 +02:00
parent a0190f8f40
commit f471214ef7
4 changed files with 290 additions and 257 deletions

View File

@ -28,72 +28,74 @@
import TimeSelection from "./generic/select/TimeSelection.svelte"; import TimeSelection from "./generic/select/TimeSelection.svelte";
import Refresher from "./generic/helper/Refresher.svelte"; import Refresher from "./generic/helper/Refresher.svelte";
export let displayType;
export let cluster = null;
export let subCluster = null;
export let from = null;
export let to = null;
const { query: initq } = init();
console.assert(
displayType == "OVERVIEW" || displayType == "LIST",
"Invalid nodes displayType provided!",
);
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 globalMetrics = getContext("globalMetrics");
const displayNodeOverview = (displayType === 'OVERVIEW')
const resampleConfig = getContext("resampling") || null;
const resampleResolutions = resampleConfig ? [...resampleConfig.resolutions] : [];
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
let selectedResolution = resampleConfig ? resampleDefault : 0;
let hostnameFilter = "";
let pendingHostnameFilter = "";
let selectedMetric = ccconfig.system_view_selectedMetric || "";
let selectedMetrics = (
ccconfig[`node_list_selectedMetrics:${cluster}:${subCluster}`] ||
ccconfig[`node_list_selectedMetrics:${cluster}`]
) || [ccconfig.system_view_selectedMetric];
let isMetricsSelectionOpen = false;
/* /*
Note 1: "Sorting" as use-case ignored for now, probably default to alphanumerical on hostnames of cluster (handled in frontend at the moment) Note 1: "Sorting" as use-case ignored for now, probably default to alphanumerical on hostnames of cluster (handled in frontend at the moment)
Note 2: Add Idle State Filter (== No allocated Jobs) [Frontend?] : Cannot be handled by CCMS, requires secondary job query and refiltering of visible nodes Note 2: Add Idle State Filter (== No allocated Jobs) [Frontend?] : Cannot be handled by CCMS, requires secondary job query and refiltering of visible nodes
*/ */
let systemMetrics = []; /* Scelte 5 Props */
let systemUnits = {}; let {
displayType,
cluster = null,
subCluster = null,
fromPreset = null,
toPreset = null,
} = $props();
function loadMetrics(isInitialized) { /* Const Init */
if (!isInitialized) return const { query: initq } = init();
systemMetrics = [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] const displayNodeOverview = (displayType === 'OVERVIEW');
for (let sm of systemMetrics) { const ccconfig = getContext("cc-config");
systemUnits[sm.name] = (sm?.unit?.prefix ? sm.unit.prefix : "") + (sm?.unit?.base ? sm.unit.base : "") const initialized = getContext("initialized");
} const globalMetrics = getContext("globalMetrics");
if (!selectedMetric) selectedMetric = systemMetrics[0].name const resampleConfig = getContext("resampling") || null;
}
$: loadMetrics($initialized) const resampleResolutions = resampleConfig ? [...resampleConfig.resolutions] : [];
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
const nowDate = new Date(Date.now());
$: if (displayNodeOverview) { /* State Init */
selectedMetrics = [selectedMetric] let to = $state(toPreset || new Date(Date.now()));
} let from = $state(fromPreset || new Date(nowDate.setHours(nowDate.getHours() - 12)));
let selectedResolution = $state(resampleConfig ? resampleDefault : 0);
let hostnameFilter = $state("");
let pendingHostnameFilter = $state("");
let isMetricsSelectionOpen = $state(false);
let selectedMetric = $state(ccconfig.system_view_selectedMetric || "");
let selectedMetrics = $state((
ccconfig[`node_list_selectedMetrics:${cluster}:${subCluster}`] ||
ccconfig[`node_list_selectedMetrics:${cluster}`]
) || [ccconfig.system_view_selectedMetric]);
$: { // Wait after input for some time to prevent too many requests /* Derived States */
setTimeout(function () { const systemMetrics = $derived($initialized ? [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] : []);
const presetSystemUnits = $derived(loadUnits(systemMetrics));
/* Effects */
$effect(() => {
// OnMount: Ping Var, without this, OVERVIEW metric select is empty (reason tbd)
systemMetrics
});
/* Functions */
function loadUnits(systemMetrics) {
let pendingUnits = {};
if (systemMetrics.length > 0) {
for (let sm of systemMetrics) {
pendingUnits[sm.name] = (sm?.unit?.prefix ? sm.unit.prefix : "") + (sm?.unit?.base ? sm.unit.base : "")
};
};
return {...pendingUnits};
};
// Wait after input for some time to prevent too many requests
let timeoutId = null;
function updateHostnameFilter() {
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(function () {
hostnameFilter = pendingHostnameFilter; hostnameFilter = pendingHostnameFilter;
}, 500); }, 500);
} };
</script> </script>
<!-- ROW1: Tools--> <!-- ROW1: Tools-->
@ -108,7 +110,7 @@
<Button <Button
outline outline
color="primary" color="primary"
on:click={() => (isMetricsSelectionOpen = true)} onclick={() => (isMetricsSelectionOpen = true)}
> >
{selectedMetrics.length} selected {selectedMetrics.length} selected
</Button> </Button>
@ -139,6 +141,7 @@
placeholder="Filter hostname ..." placeholder="Filter hostname ..."
type="text" type="text"
bind:value={pendingHostnameFilter} bind:value={pendingHostnameFilter}
oninput={updateHostnameFilter}
/> />
</InputGroup> </InputGroup>
</Col> </Col>
@ -153,26 +156,28 @@
<InputGroupText><Icon name="graph-up" /></InputGroupText> <InputGroupText><Icon name="graph-up" /></InputGroupText>
<InputGroupText>Metric</InputGroupText> <InputGroupText>Metric</InputGroupText>
<Input type="select" bind:value={selectedMetric}> <Input type="select" bind:value={selectedMetric}>
{#each systemMetrics as metric} {#each systemMetrics as metric (metric.name)}
<option value={metric.name} <option value={metric.name}
>{metric.name} {systemUnits[metric.name] ? "("+systemUnits[metric.name]+")" : ""}</option >{metric.name} {presetSystemUnits[metric.name] ? "("+presetSystemUnits[metric.name]+")" : ""}</option
> >
{:else}
<option disabled>No available options</option>
{/each} {/each}
</Input> </Input>
</InputGroup> </InputGroup>
</Col> </Col>
{/if} {/if}
<!-- Refresh Col-->
<Col class="mt-2 mt-lg-0">
<Refresher
onRefresh={() => {
const diff = Date.now() - to;
from = new Date(from.getTime() + diff);
to = new Date(to.getTime() + diff);
}}
/>
</Col>
{/if} {/if}
<!-- Refresh Col-->
<Col class="mt-2 mt-lg-0">
<Refresher
onRefresh={() => {
const diff = Date.now() - to;
from = new Date(from.getTime() + diff);
to = new Date(to.getTime() + diff);
}}
/>
</Col>
</Row> </Row>
<!-- ROW2: Content--> <!-- ROW2: Content-->
@ -185,20 +190,22 @@
{:else} {:else}
{#if displayNodeOverview} {#if displayNodeOverview}
<!-- ROW2-1: Node Overview (Grid Included)--> <!-- ROW2-1: Node Overview (Grid Included)-->
<NodeOverview {cluster} {subCluster} {ccconfig} {selectedMetrics} {from} {to} {hostnameFilter}/> <NodeOverview {cluster} {ccconfig} {selectedMetric} {from} {to} {hostnameFilter}/>
{:else} {:else}
<!-- ROW2-2: Node List (Grid Included)--> <!-- ROW2-2: Node List (Grid Included)-->
<NodeList {cluster} {subCluster} {ccconfig} {selectedMetrics} {selectedResolution} {hostnameFilter} {from} {to} {systemUnits}/> <NodeList {cluster} {subCluster} {ccconfig} {selectedMetrics} {selectedResolution} {hostnameFilter} {from} {to} {presetSystemUnits}/>
{/if} {/if}
{/if} {/if}
<MetricSelection {#if !displayNodeOverview}
bind:isOpen={isMetricsSelectionOpen} <MetricSelection
presetMetrics={selectedMetrics} bind:isOpen={isMetricsSelectionOpen}
{cluster} presetMetrics={selectedMetrics}
{subCluster} {cluster}
configName="node_list_selectedMetrics" {subCluster}
applyMetrics={(newMetrics) => configName="node_list_selectedMetrics"
selectedMetrics = [...newMetrics] applyMetrics={(newMetrics) =>
} selectedMetrics = [...newMetrics]
/> }
/>
{/if}

View File

@ -8,8 +8,8 @@ mount(Systems, {
displayType: displayType, displayType: displayType,
cluster: infos.cluster, cluster: infos.cluster,
subCluster: infos.subCluster, subCluster: infos.subCluster,
from: infos.from, fromPreset: infos.from,
to: infos.to toPreset: infos.to
}, },
context: new Map([ context: new Map([
['cc-config', clusterCockpitConfig], ['cc-config', clusterCockpitConfig],

View File

@ -10,38 +10,28 @@
--> -->
<script> <script>
import { getContext } from "svelte";
import { queryStore, gql, getContextClient, mutationStore } from "@urql/svelte"; import { queryStore, gql, getContextClient, mutationStore } from "@urql/svelte";
import { Row, Col, Card, Table, Spinner } from "@sveltestrap/sveltestrap"; import { Row, Col, Card, Table, Spinner } from "@sveltestrap/sveltestrap";
import { stickyHeader } from "../generic/utils.js"; import { stickyHeader } from "../generic/utils.js";
import NodeListRow from "./nodelist/NodeListRow.svelte"; import NodeListRow from "./nodelist/NodeListRow.svelte";
import Pagination from "../generic/joblist/Pagination.svelte"; import Pagination from "../generic/joblist/Pagination.svelte";
export let cluster; /* Svelte 5 Props */
export let subCluster = ""; let {
export let ccconfig = null; cluster,
export let selectedMetrics = []; subCluster = "",
export let selectedResolution = 0; ccconfig = null,
export let hostnameFilter = ""; selectedMetrics = [],
export let systemUnits = null; selectedResolution = 0,
export let from = null; hostnameFilter = "",
export let to = null; presetSystemUnits = null,
from = null,
to = null
} = $props();
// Decouple from Job List Paging Params? /* Const Init */
let usePaging = ccconfig?.node_list_usePaging || false
let itemsPerPage = usePaging ? (ccconfig?.plot_list_nodesPerPage || 10) : 10;
let page = 1;
let paging = { itemsPerPage, page };
let headerPaddingTop = 0;
stickyHeader(
".cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)",
(x) => (headerPaddingTop = x),
);
// const { query: initq } = init();
const initialized = getContext("initialized");
const client = getContextClient(); const client = getContextClient();
const usePaging = ccconfig?.node_list_usePaging || false;
const nodeListQuery = gql` const nodeListQuery = gql`
query ($cluster: String!, $subCluster: String!, $nodeFilter: String!, $metrics: [String!], $scopes: [MetricScope!]!, $from: Time!, $to: Time!, $paging: PageRequest!, $selectedResolution: Int) { query ($cluster: String!, $subCluster: String!, $nodeFilter: String!, $metrics: [String!], $scopes: [MetricScope!]!, $from: Time!, $to: Time!, $paging: PageRequest!, $selectedResolution: Int) {
nodeMetricsList( nodeMetricsList(
@ -91,51 +81,17 @@
} }
` `
const updateConfigurationMutation = ({ name, value }) => { /* State Init */
return mutationStore({ let nodes = $state([]);
client: client, let page = $state(1);
query: gql` let itemsPerPage = $state(usePaging ? (ccconfig?.plot_list_nodesPerPage || 10) : 10);
mutation ($name: String!, $value: String!) { let headerPaddingTop = $state(0);
updateConfiguration(name: $name, value: $value)
}
`,
variables: { name, value },
});
};
// Decouple from Job List Paging Params? /* Derived */
function updateConfiguration(value, page) { const paging = $derived({ itemsPerPage, page });
updateConfigurationMutation({ const matchedNodes = $derived($nodesQuery?.data?.nodeMetricsList?.totalNodes || 0);
name: "plot_list_nodesPerPage",
value: value,
}).subscribe((res) => {
if (res.fetching === false && !res.error) {
nodes = [] // Empty List
paging = { itemsPerPage: value, page: page }; // Trigger reload of nodeList
} else if (res.fetching === false && res.error) {
throw res.error;
}
});
}
if (!usePaging) { const nodesQuery = $derived(queryStore({
window.addEventListener('scroll', () => {
let {
scrollTop,
scrollHeight,
clientHeight
} = document.documentElement;
// Add 100 px offset to trigger load earlier
if (scrollTop + clientHeight >= scrollHeight - 100 && $nodesQuery?.data != null && $nodesQuery.data?.nodeMetricsList.hasNextPage) {
let pendingPaging = { ...paging }
pendingPaging.page += 1
paging = pendingPaging
};
});
};
$: nodesQuery = queryStore({
client: client, client: client,
query: nodeListQuery, query: nodeListQuery,
variables: { variables: {
@ -150,24 +106,92 @@
selectedResolution: selectedResolution, selectedResolution: selectedResolution,
}, },
requestPolicy: "network-only", // Resolution queries are cached, but how to access them? For now: reload on every change requestPolicy: "network-only", // Resolution queries are cached, but how to access them? For now: reload on every change
}));
/* Effects */
$effect(() => {
if (!usePaging) {
window.addEventListener('scroll', () => {
let {
scrollTop,
scrollHeight,
clientHeight
} = document.documentElement;
// Add 100 px offset to trigger load earlier
if (scrollTop + clientHeight >= scrollHeight - 100 && $nodesQuery?.data != null && $nodesQuery.data?.nodeMetricsList.hasNextPage) {
page += 1
};
});
};
}); });
let nodes = []; $effect(() => {
$: if ($initialized && $nodesQuery.data) { handleNodes($nodesQuery?.data?.nodeMetricsList?.items);
if (usePaging) { });
nodes = [...$nodesQuery.data.nodeMetricsList.items].sort((a, b) => a.host.localeCompare(b.host));
} else { $effect(() => {
nodes = nodes.concat([...$nodesQuery.data.nodeMetricsList.items].sort((a, b) => a.host.localeCompare(b.host))) // Triggers (Except Paging)
from, to
selectedMetrics, selectedResolution
hostnameFilter
// Continous Scroll: Reset nodes and paging if parameters change: Existing entries will not match new selections
if (!usePaging) {
nodes = [];
page = 1;
} }
});
/* Functions */
function handleNodes(data) {
if (data) {
if (usePaging || nodes.length == 0) {
nodes = [...data].sort((a, b) => a.host.localeCompare(b.host));
} else {
// Workaround to ignore secondary store triggers (reason tbd)
const oldNodes = $state.snapshot(nodes)
const newNodes = [...data].map((d) => d.host)
if (!oldNodes.some((n) => newNodes.includes(n.host))) {
nodes = nodes.concat([...data].sort((a, b) => a.host.localeCompare(b.host)))
};
};
};
};
// Decouple from Job List Paging Params?
function updateConfiguration(newItems, newPage) {
updateConfigurationMutation({
name: "plot_list_nodesPerPage",
value: newItems.toString(),
}).subscribe((res) => {
if (res.fetching === false && !res.error) {
nodes = []; // Empty List
itemsPerPage = newItems;
page = newPage; // Trigger reload of nodeList
} else if (res.fetching === false && res.error) {
throw res.error;
}
});
} }
$: if (!usePaging && (selectedMetrics || selectedResolution || hostnameFilter || from || to)) { /* Const Functions */
// Continous Scroll: Reset list and paging if parameters change: Existing entries will not match new selections const updateConfigurationMutation = ({ name, value }) => {
nodes = []; return mutationStore({
paging = { itemsPerPage, page: 1 }; client: client,
} query: gql`
mutation ($name: String!, $value: String!) {
updateConfiguration(name: $name, value: $value)
}
`,
variables: { name, value },
});
};
$: matchedNodes = $nodesQuery.data?.nodeMetricsList.totalNodes || matchedNodes; /* Init Header */
stickyHeader(
".cc-table-wrapper > table.table >thead > tr > th.position-sticky:nth-child(1)",
(x) => (headerPaddingTop = x),
);
</script> </script>
<Row> <Row>
@ -192,7 +216,7 @@
scope="col" scope="col"
style="padding-top: {headerPaddingTop}px" style="padding-top: {headerPaddingTop}px"
> >
{metric} ({systemUnits[metric]}) {metric} ({presetSystemUnits[metric]})
</th> </th>
{/each} {/each}
</tr> </tr>
@ -212,7 +236,7 @@
<td colspan={selectedMetrics.length + 1}> No nodes found </td> <td colspan={selectedMetrics.length + 1}> No nodes found </td>
</tr> </tr>
{/each} {/each}
{/if} {/if}
{#if $nodesQuery.fetching || !$nodesQuery.data} {#if $nodesQuery.fetching || !$nodesQuery.data}
<tr> <tr>
<td colspan={selectedMetrics.length + 1}> <td colspan={selectedMetrics.length + 1}>
@ -244,10 +268,11 @@
totalItems={matchedNodes} totalItems={matchedNodes}
on:update-paging={({ detail }) => { on:update-paging={({ detail }) => {
if (detail.itemsPerPage != itemsPerPage) { if (detail.itemsPerPage != itemsPerPage) {
updateConfiguration(detail.itemsPerPage.toString(), detail.page); updateConfiguration(detail.itemsPerPage, detail.page);
} else { } else {
nodes = [] nodes = [];
paging = { itemsPerPage: detail.itemsPerPage, page: detail.page }; itemsPerPage = detail.itemsPerPage;
page = detail.page;
} }
}} }}
/> />

View File

@ -10,101 +10,99 @@
<script> <script>
import { queryStore, gql, getContextClient } from "@urql/svelte"; import { queryStore, gql, getContextClient } from "@urql/svelte";
import { Row, Col, Card, Spinner } from "@sveltestrap/sveltestrap"; import { Row, Col, Card, Spinner } from "@sveltestrap/sveltestrap";
import { init, checkMetricsDisabled } from "../generic/utils.js"; import { checkMetricDisabled } from "../generic/utils.js";
import MetricPlot from "../generic/plots/MetricPlot.svelte"; import MetricPlot from "../generic/plots/MetricPlot.svelte";
export let ccconfig = null; /* Svelte 5 Props */
export let cluster = ""; let {
export const subCluster = ""; ccconfig = null,
export let selectedMetrics = null; cluster = "",
export let hostnameFilter = ""; selectedMetric = "",
export let from = null; hostnameFilter = "",
export let to = null; from = null,
to = null
} = $props();
const { query: initq } = init(); /* Const Init */
const client = getContextClient(); const client = getContextClient();
const nodeQuery = gql`
query ($cluster: String!, $metrics: [String!], $from: Time!, $to: Time!) { /* Derived */
nodeMetrics( const nodesQuery = $derived(queryStore({
cluster: $cluster client: client,
metrics: $metrics query: gql`
from: $from query ($cluster: String!, $metrics: [String!], $from: Time!, $to: Time!) {
to: $to nodeMetrics(
) { cluster: $cluster
host metrics: $metrics
subCluster from: $from
metrics { to: $to
name ) {
scope host
metric { subCluster
timestep metrics {
unit { name
base scope
prefix metric {
} timestep
series { unit {
statistics { base
min prefix
avg }
max series {
statistics {
min
avg
max
}
data
} }
data
} }
} }
} }
} }
} `,
`
$: selectedMetric = selectedMetrics[0] ? selectedMetrics[0] : "";
$: nodesQuery = queryStore({
client: client,
query: nodeQuery,
variables: { variables: {
cluster: cluster, cluster: cluster,
metrics: selectedMetrics, metrics: [selectedMetric],
from: from.toISOString(), from: from,
to: to.toISOString(), to: to,
}, },
}); }));
let rawData = [] const mappedData = $derived(handleQueryData($nodesQuery?.data));
$: if ($initq.data && $nodesQuery?.data) { const filteredData = $derived(mappedData.filter((h) => h.host.includes(hostnameFilter)));
rawData = $nodesQuery?.data?.nodeMetrics.filter((h) => {
if (h.subCluster === '') { // Exclude nodes with empty subCluster field
console.warn('subCluster not configured for node', h.host)
return false
} else {
return h.metrics.some(
(m) => selectedMetrics.includes(m.name) && m.scope == "node",
)
}
})
}
let mappedData = [] /* Functions */
$: if (rawData?.length > 0) { function handleQueryData(queryData) {
mappedData = rawData.map((h) => ({ let rawData = []
host: h.host, if (queryData) {
subCluster: h.subCluster, rawData = queryData.nodeMetrics.filter((h) => {
data: h.metrics.filter( if (h.subCluster !== '') { // Exclude nodes with empty subCluster field
(m) => selectedMetrics.includes(m.name) && m.scope == "node", return h.metrics.some(
), (m) => m?.name == selectedMetric && m.scope == "node",
disabled: checkMetricsDisabled( )
selectedMetrics, };
cluster, });
h.subCluster, };
),
})) let pendingMapped = [];
.sort((a, b) => a.host.localeCompare(b.host)) if (rawData.length > 0) {
} pendingMapped = rawData.map((h) => ({
host: h.host,
let filteredData = [] subCluster: h.subCluster,
$: if (mappedData?.length > 0) { data: h.metrics.filter(
filteredData = mappedData.filter((h) => (m) => m?.name == selectedMetric && m.scope == "node",
h.host.includes(hostnameFilter) ),
) disabled: checkMetricDisabled(
selectedMetric,
cluster,
h.subCluster,
),
}))
.sort((a, b) => a.host.localeCompare(b.host))
}
return pendingMapped;
} }
</script> </script>
@ -132,22 +130,25 @@
>{item.host} ({item.subCluster})</a >{item.host} ({item.subCluster})</a
> >
</h4> </h4>
{#if item?.disabled[selectedMetric]} {#if item?.disabled}
<Card body class="mx-3" color="info" <Card body class="mx-3" color="info"
>Metric disabled for subcluster <code >Metric disabled for subcluster <code
>{selectedMetric}:{item.subCluster}</code >{selectedMetric}:{item.subCluster}</code
></Card ></Card
> >
{:else} {:else}
<!-- "No Data"-Warning included in MetricPlot-Component --> <!-- "No Data"-Warning included in MetricPlot-Component -->
<MetricPlot <!-- #key: X-axis keeps last selected timerange otherwise -->
timestep={item.data[0].metric.timestep} {#key item.data[0].metric.series[0].data.length}
series={item.data[0].metric.series} <MetricPlot
metric={item.data[0].name} timestep={item.data[0].metric.timestep}
{cluster} series={item.data[0].metric.series}
subCluster={item.subCluster} metric={item.data[0].name}
forNode {cluster}
/> subCluster={item.subCluster}
forNode
/>
{/key}
{/if} {/if}
</Col> </Col>
{/each} {/each}