mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-02-20 17:57:30 +01:00
396 lines
11 KiB
Svelte
396 lines
11 KiB
Svelte
<!--
|
|
@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();
|
|
const stateOptions = [
|
|
"all",
|
|
"allocated",
|
|
"idle",
|
|
"down",
|
|
"mixed",
|
|
"reserved",
|
|
"unknown",
|
|
];
|
|
const healthOptions = [
|
|
"all",
|
|
"full",
|
|
"partial",
|
|
"failed",
|
|
];
|
|
|
|
/* State Init */
|
|
let pieWidth = $state(0);
|
|
let tableHostFilter = $state("");
|
|
let tableStateFilter = $state(stateOptions[0]);
|
|
let tableHealthFilter = $state(healthOptions[0]);
|
|
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 != "all") {
|
|
pendingTableData = pendingTableData.filter((e) => e.schedulerState.includes(tableStateFilter))
|
|
}
|
|
if (tableHealthFilter != "all") {
|
|
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 responsive>
|
|
<thead>
|
|
<!-- Header Row 1: Titles and Sorting -->
|
|
<tr>
|
|
<th style="width: 9%; min-width: 100px; max-width:10%;" onclick={() => sortBy('hostname')}>
|
|
Hosts ({filteredTableData.length})
|
|
<Icon
|
|
name="caret-{healthTableSorting['hostname'].dir}{healthTableSorting['hostname']
|
|
.active
|
|
? '-fill'
|
|
: ''}"
|
|
/>
|
|
</th>
|
|
<th style="width: 9%; min-width: 100px; max-width:10%;" onclick={() => sortBy('schedulerState')}>
|
|
Scheduler State
|
|
<Icon
|
|
name="caret-{healthTableSorting['schedulerState'].dir}{healthTableSorting['schedulerState']
|
|
.active
|
|
? '-fill'
|
|
: ''}"
|
|
/>
|
|
</th>
|
|
<th style="width: 9%; min-width: 100px; max-width:10%;" 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="select" bind:value={tableStateFilter}>
|
|
{#each stateOptions as so}
|
|
<option value={so}>{so}</option>
|
|
{/each}
|
|
</Input>
|
|
<InputGroupText>
|
|
<Icon name="search"></Icon>
|
|
</InputGroupText>
|
|
</InputGroup>
|
|
</th>
|
|
<th>
|
|
<InputGroup size="sm">
|
|
<Input type="select" bind:value={tableHealthFilter}>
|
|
{#each healthOptions as ho}
|
|
<option value={ho}>{ho}</option>
|
|
{/each}
|
|
</Input>
|
|
<InputGroupText>
|
|
<Icon name="search"></Icon>
|
|
</InputGroupText>
|
|
</InputGroup>
|
|
</th>
|
|
<th></th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each filteredTableData as host (host.hostname)}
|
|
<tr>
|
|
<th scope="row"><b><a href="/monitoring/node/{cluster}/{host.hostname}" target="_blank">{host.hostname}</a></b></th>
|
|
<td>{host.schedulerState}</td>
|
|
<td>{host.healthState}</td>
|
|
<td style="max-width: 250px;">
|
|
{#each Object.keys(host.healthData) as hkey}
|
|
<p>
|
|
<b>{hkey}</b>: {host.healthData[hkey]}
|
|
</p>
|
|
{/each}
|
|
</td>
|
|
<td style="max-width: 250px;">
|
|
{#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}
|