mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-02-18 00:41:46 +01:00
add healthStatus tab to status details, add graphql endpoints and handlers
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
372
web/frontend/src/status/dashdetails/HealthDash.svelte
Normal file
372
web/frontend/src/status/dashdetails/HealthDash.svelte
Normal 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}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user