add healthStatus tab to status details, add graphql endpoints and handlers

This commit is contained in:
Christoph Kluge
2026-02-17 14:38:06 +01:00
parent 9af44779aa
commit fe3ebe0abc
8 changed files with 676 additions and 158 deletions

View File

@@ -23,6 +23,7 @@
} from "@sveltestrap/sveltestrap";
import StatusDash from "./dashdetails/StatusDash.svelte";
import HealthDash from "./dashdetails/HealthDash.svelte";
import UsageDash from "./dashdetails/UsageDash.svelte";
import StatisticsDash from "./dashdetails/StatisticsDash.svelte";
@@ -65,7 +66,13 @@
<TabContent>
<TabPane tabId="status-dash" tab="Status" active>
<CardBody>
<StatusDash clusters={$initq.data.clusters} {presetCluster} {useCbColors} useAltColors></StatusDash>
<StatusDash clusters={$initq.data.clusters} {presetCluster}></StatusDash>
</CardBody>
</TabPane>
<TabPane tabId="health-dash" tab="Metric Status">
<CardBody>
<HealthDash {presetCluster}></HealthDash>
</CardBody>
</TabPane>

View File

@@ -0,0 +1,372 @@
<!--
@component Main cluster status view component; renders current system-usage information
Properties:
- `presetCluster String`: The cluster to show status information for
-->
<script>
import {
Row,
Col,
Card,
Input,
InputGroup,
InputGroupText,
Table,
Icon,
Spinner
} from "@sveltestrap/sveltestrap";
import {
queryStore,
gql,
getContextClient,
} from "@urql/svelte";
import Refresher from "../../generic/helper/Refresher.svelte";
import Pie, { colors } from "../../generic/plots/Pie.svelte";
/* Svelte 5 Props */
let {
presetCluster,
} = $props();
/* Const Init */
const client = getContextClient();
/* State Init */
let pieWidth = $state(0);
let tableHostFilter = $state("");
let tableStateFilter = $state("");
let tableHealthFilter = $state("");
let healthTableSorting = $state(
{
schedulerState: { dir: "down", active: true },
healthState: { dir: "down", active: false },
hostname: { dir: "down", active: false },
}
);
/* Derived */
let cluster = $derived(presetCluster);
const statusQuery = $derived(queryStore({
client: client,
query: gql`
query (
$nodeFilter: [NodeFilter!]!
$sorting: OrderByInput!
) {
# $sorting unused in backend: Use placeholder
nodes: nodesWithMeta(filter: $nodeFilter, order: $sorting) {
count
items {
hostname
cluster
subCluster
schedulerState
healthState
metaData
healthData
}
}
# Get Current States for Pie Charts
nodeStates(filter: $nodeFilter) {
state
count
},
}
`,
variables: {
nodeFilter: { cluster: { eq: cluster }},
sorting: { field: "startTime", type: "col", order: "DESC" },
},
requestPolicy: "network-only"
}));
let healthTableData = $derived.by(() => {
if ($statusQuery?.data) {
return [...$statusQuery.data.nodes.items].sort((n1, n2) => {
return n1['schedulerState'].localeCompare(n2['schedulerState'])
});
} else {
return [];
}
});
let filteredTableData = $derived.by(() => {
let pendingTableData = [...healthTableData];
if (tableHostFilter != "") {
pendingTableData = pendingTableData.filter((e) => e.hostname.includes(tableHostFilter))
}
if (tableStateFilter != "") {
pendingTableData = pendingTableData.filter((e) => e.schedulerState.includes(tableStateFilter))
}
if (tableHealthFilter != "") {
pendingTableData = pendingTableData.filter((e) => e.healthState.includes(tableHealthFilter))
}
return pendingTableData
});
const refinedStateData = $derived.by(() => {
return $statusQuery?.data?.nodeStates.
filter((e) => ['allocated', 'reserved', 'idle', 'mixed','down', 'unknown'].includes(e.state)).
sort((a, b) => b.count - a.count)
});
const refinedHealthData = $derived.by(() => {
return $statusQuery?.data?.nodeStates.
filter((e) => ['full', 'partial', 'failed'].includes(e.state)).
sort((a, b) => b.count - a.count)
});
/* Functions */
function sortBy(field) {
const s = healthTableSorting[field];
if (s.active) {
s.dir = s.dir == "up" ? "down" : "up";
} else {
for (let key in healthTableSorting)
healthTableSorting[key].active = false;
s.active = true;
}
const pendingHealthData = healthTableData.sort((n1, n2) => {
if (n1[field] == null || n2[field] == null) return -1;
else if (s.dir == "down") return n1[field].localeCompare(n2[field])
else return n2[field].localeCompare(n1[field])
});
healthTableSorting = {...healthTableSorting};
healthTableData = [...pendingHealthData];
}
</script>
<!-- Refresher and space for other options -->
<Row class="justify-content-between">
<Col xs="12" md="5" lg="4" xl="3">
<Refresher
initially={120}
onRefresh={(interval) => {
sorting = { field: "startTime", type: "col", order: "DESC" }
}}
/>
</Col>
</Row>
<hr/>
<!-- Node Health Pis, later Charts -->
{#if $statusQuery.fetching}
<Row cols={1} class="text-center mt-3">
<Col>
<Spinner />
</Col>
</Row>
{:else if $statusQuery.error}
<Row cols={1} class="text-center mt-3">
<Col>
<Card body color="danger">Status Query (States): {$statusQuery.error.message}</Card>
</Col>
</Row>
{:else if $statusQuery?.data?.nodeStates}
<Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center">
<Col class="px-3 mt-2 mt-lg-0">
<div bind:clientWidth={pieWidth}>
{#key refinedStateData}
<h4 class="text-center">
Current {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States
</h4>
<Pie
canvasId="hpcpie-slurm"
size={pieWidth * 0.55}
sliceLabel="Nodes"
quantities={refinedStateData.map(
(sd) => sd.count,
)}
entities={refinedStateData.map(
(sd) => sd.state,
)}
fixColors={refinedStateData.map(
(sd) => colors['nodeStates'][sd.state],
)}
/>
{/key}
</div>
</Col>
<Col class="px-4 py-2">
{#key refinedStateData}
<Table>
<tr class="mb-2">
<th></th>
<th>Current State</th>
<th>Nodes</th>
</tr>
{#each refinedStateData as sd, i}
<tr>
<td><Icon name="circle-fill" style="color: {colors['nodeStates'][sd.state]};"/></td>
<td>{sd.state}</td>
<td>{sd.count}</td>
</tr>
{/each}
</Table>
{/key}
</Col>
<Col class="px-3 mt-2 mt-lg-0">
<div bind:clientWidth={pieWidth}>
{#key refinedHealthData}
<h4 class="text-center">
Current {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node Health
</h4>
<Pie
canvasId="hpcpie-health"
size={pieWidth * 0.55}
sliceLabel="Nodes"
quantities={refinedHealthData.map(
(hd) => hd.count,
)}
entities={refinedHealthData.map(
(hd) => hd.state,
)}
fixColors={refinedHealthData.map(
(hd) => colors['healthStates'][hd.state],
)}
/>
{/key}
</div>
</Col>
<Col class="px-4 py-2">
{#key refinedHealthData}
<Table>
<tr class="mb-2">
<th></th>
<th>Current Health</th>
<th>Nodes</th>
</tr>
{#each refinedHealthData as hd, i}
<tr>
<td><Icon name="circle-fill"style="color: {colors['healthStates'][hd.state]};" /></td>
<td>{hd.state}</td>
<td>{hd.count}</td>
</tr>
{/each}
</Table>
{/key}
</Col>
</Row>
{/if}
<hr/>
<!-- Tabular Info About Node States and Missing Metrics -->
{#if $statusQuery.fetching}
<Row cols={1} class="text-center mt-3">
<Col>
<Spinner />
</Col>
</Row>
{:else if $statusQuery.error}
<Row cols={1} class="text-center mt-3">
<Col>
<Card body color="danger">Status Query (Details): {$statusQuery.error.message}</Card>
</Col>
</Row>
{:else if $statusQuery.data}
<Row>
<Col>
<Card>
<Table hover>
<thead>
<!-- Header Row 1: Titles and Sorting -->
<tr>
<th style="width:10%; max-width:12.5%;" onclick={() => sortBy('hostname')}>
Host
<Icon
name="caret-{healthTableSorting['hostname'].dir}{healthTableSorting['hostname']
.active
? '-fill'
: ''}"
/>
</th>
<th style="width:10%; max-width:12.5%;" onclick={() => sortBy('schedulerState')}>
Scheduler State
<Icon
name="caret-{healthTableSorting['schedulerState'].dir}{healthTableSorting['schedulerState']
.active
? '-fill'
: ''}"
/>
</th>
<th style="width:10%; max-width:12.5%;" onclick={() => sortBy('healthState')}>
Health State
<Icon
name="caret-{healthTableSorting['healthState'].dir}{healthTableSorting['healthState']
.active
? '-fill'
: ''}"
/>
</th>
<th>Metric Availability</th>
<th>Meta Information</th>
</tr>
<!-- Header Row 2: Filters -->
<tr>
<th>
<InputGroup size="sm">
<Input type="text" bind:value={tableHostFilter}/>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
</InputGroup>
</th>
<th>
<InputGroup size="sm">
<Input type="text" bind:value={tableStateFilter}/>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
</InputGroup>
</th>
<th>
<InputGroup size="sm">
<Input type="text" bind:value={tableHealthFilter}/>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
</InputGroup>
</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{#each filteredTableData as host (host.hostname)}
<tr>
<th><b><a href="/monitoring/node/{cluster}/{host.hostname}" target="_blank">{host.hostname}</a></b></th>
<td>{host.schedulerState}</td>
<td>{host.healthState}</td>
<td>
{#each Object.keys(host.healthData) as hkey}
<p>
<b>{hkey}</b>: {host.healthData[hkey]}
</p>
{/each}
</td>
<td>
{#each Object.keys(host.metaData) as mkey}
<p>
<b>{mkey}</b>: {host.metaData[mkey]}
</p>
{/each}
</td>
</tr>
{/each}
</tbody>
</Table>
</Card>
</Col>
</Row>
{:else}
<Card class="mx-4" body color="warning">Cannot render metric health info: No data!</Card>
{/if}

View File

@@ -15,7 +15,6 @@
CardBody,
Table,
Progress,
Icon,
Spinner
} from "@sveltestrap/sveltestrap";
import {
@@ -27,22 +26,18 @@
import Refresher from "../../generic/helper/Refresher.svelte";
import TimeSelection from "../../generic/select/TimeSelection.svelte";
import Roofline from "../../generic/plots/Roofline.svelte";
import Pie, { colors } from "../../generic/plots/Pie.svelte";
import Stacked from "../../generic/plots/Stacked.svelte";
/* Svelte 5 Props */
let {
clusters,
presetCluster,
useCbColors = false,
useAltColors = false,
} = $props();
/* Const Init */
const client = getContextClient();
/* State Init */
let pieWidth = $state(0);
let from = $state(new Date(Date.now() - 5 * 60 * 1000));
let to = $state(new Date(Date.now()));
let stackedFrom = $state(Math.floor(Date.now() / 1000) - 14400);
@@ -163,11 +158,6 @@
schedulerState
}
}
# Get Current States fir Pie Charts
nodeStates(filter: $nodeFilter) {
state
count
}
# totalNodes includes multiples if shared jobs
jobsStatistics(
filter: $jobFilter
@@ -196,18 +186,6 @@
requestPolicy: "network-only"
}));
const refinedStateData = $derived.by(() => {
return $statusQuery?.data?.nodeStates.
filter((e) => ['allocated', 'reserved', 'idle', 'mixed','down', 'unknown'].includes(e.state)).
sort((a, b) => b.count - a.count)
});
const refinedHealthData = $derived.by(() => {
return $statusQuery?.data?.nodeStates.
filter((e) => ['full', 'partial', 'failed'].includes(e.state)).
sort((a, b) => b.count - a.count)
});
/* Effects */
$effect(() => {
if ($statusQuery.data) {
@@ -367,19 +345,6 @@
return result
}
function legendColors(targetIdx) {
// Reuses first color if targetIdx overflows
let c;
if (useCbColors) {
c = [...colors['colorblind']];
} else if (useAltColors) {
c = [...colors['alternative']];
} else {
c = [...colors['default']];
}
return c[(c.length + targetIdx) % c.length];
}
</script>
<!-- Refresher and space for other options -->
@@ -408,7 +373,7 @@
<hr/>
<!-- Node Stack Charts Dev-->
<!-- Node Stack Charts -->
{#if $statesTimed.fetching}
<Row cols={1} class="text-center mt-3">
<Col>
@@ -460,109 +425,6 @@
</Row>
{/if}
<hr/>
<!-- Node Health Pis, later Charts -->
{#if $statusQuery.fetching}
<Row cols={1} class="text-center mt-3">
<Col>
<Spinner />
</Col>
</Row>
{:else if $statusQuery.error}
<Row cols={1} class="text-center mt-3">
<Col>
<Card body color="danger">Status Query (States): {$statesTimed.error.message}</Card>
</Col>
</Row>
{:else if $statusQuery?.data?.nodeStates}
<Row cols={{ lg: 4, md: 2 , sm: 1}} class="mb-3 justify-content-center">
<Col class="px-3 mt-2 mt-lg-0">
<div bind:clientWidth={pieWidth}>
{#key refinedStateData}
<h4 class="text-center">
Current {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node States
</h4>
<Pie
canvasId="hpcpie-slurm"
size={pieWidth * 0.55}
sliceLabel="Nodes"
quantities={refinedStateData.map(
(sd) => sd.count,
)}
entities={refinedStateData.map(
(sd) => sd.state,
)}
fixColors={refinedStateData.map(
(sd) => colors['nodeStates'][sd.state],
)}
/>
{/key}
</div>
</Col>
<Col class="px-4 py-2">
{#key refinedStateData}
<Table>
<tr class="mb-2">
<th></th>
<th>Current State</th>
<th>Nodes</th>
</tr>
{#each refinedStateData as sd, i}
<tr>
<td><Icon name="circle-fill" style="color: {colors['nodeStates'][sd.state]};"/></td>
<td>{sd.state}</td>
<td>{sd.count}</td>
</tr>
{/each}
</Table>
{/key}
</Col>
<Col class="px-3 mt-2 mt-lg-0">
<div bind:clientWidth={pieWidth}>
{#key refinedHealthData}
<h4 class="text-center">
Current {cluster.charAt(0).toUpperCase() + cluster.slice(1)} Node Health
</h4>
<Pie
canvasId="hpcpie-health"
size={pieWidth * 0.55}
sliceLabel="Nodes"
quantities={refinedHealthData.map(
(hd) => hd.count,
)}
entities={refinedHealthData.map(
(hd) => hd.state,
)}
fixColors={refinedHealthData.map(
(hd) => colors['healthStates'][hd.state],
)}
/>
{/key}
</div>
</Col>
<Col class="px-4 py-2">
{#key refinedHealthData}
<Table>
<tr class="mb-2">
<th></th>
<th>Current Health</th>
<th>Nodes</th>
</tr>
{#each refinedHealthData as hd, i}
<tr>
<td><Icon name="circle-fill"style="color: {colors['healthStates'][hd.state]};" /></td>
<td>{hd.state}</td>
<td>{hd.count}</td>
</tr>
{/each}
</Table>
{/key}
</Col>
</Row>
{/if}
<hr/>
<!-- Gauges & Roofline per Subcluster-->
{#if $statusQuery.fetching}