Files
cc-backend/web/frontend/src/Systems.root.svelte
2025-11-20 12:18:13 +01:00

288 lines
9.0 KiB
Svelte

<!--
@component Main cluster node status view component; renders overview or list depending on type
Properties:
- `displayType String?`: The type of node display ['OVERVIEW' || 'LIST']
- `cluster String`: The cluster to show status information for [Default: null]
- `subCluster String`: The subCluster to show status information for [Default: null]
- `presetFrom Date?`: Custom Time Range selection 'from' [Default: null]
- `presetTo Date?`: Custom Time Range selection 'to' [Default: null]
-->
<script>
import { getContext } from "svelte";
import {
Row,
Col,
Card,
Input,
InputGroup,
InputGroupText,
Icon,
Button,
} from "@sveltestrap/sveltestrap";
import {
gql,
getContextClient,
mutationStore,
} from "@urql/svelte";
import { init } from "./generic/utils.js";
import NodeOverview from "./systems/NodeOverview.svelte";
import NodeList from "./systems/NodeList.svelte";
import MetricSelection from "./generic/select/MetricSelection.svelte";
import TimeSelection from "./generic/select/TimeSelection.svelte";
import Refresher from "./generic/helper/Refresher.svelte";
/*
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
*/
/* Scelte 5 Props */
let {
displayType,
cluster = null,
subCluster = null,
presetFrom = null,
presetTo = null,
} = $props();
/* Const Init */
const { query: initq } = init();
const client = getContextClient();
const displayNodeOverview = (displayType === 'OVERVIEW');
const ccconfig = getContext("cc-config");
const initialized = getContext("initialized");
const globalMetrics = getContext("globalMetrics");
const resampleConfig = getContext("resampling") || null;
const resampleResolutions = resampleConfig ? [...resampleConfig.resolutions] : [];
const resampleDefault = resampleConfig ? Math.max(...resampleConfig.resolutions) : 0;
const stateOptions = ['all', 'allocated', 'idle', 'reserved', 'mixed', 'down', 'unknown', 'notindb'];
const nowDate = new Date(Date.now());
/* Var Init */
let timeoutId = null;
/* State Init */
let to = $state(presetTo || new Date(Date.now()));
let from = $state(presetFrom || new Date(nowDate.setHours(nowDate.getHours() - 4)));
let selectedResolution = $state(resampleConfig ? resampleDefault : 0);
let hostnameFilter = $state("");
let hoststateFilter = $state("all");
let pendingHostnameFilter = $state("");
let isMetricsSelectionOpen = $state(false);
/* Derived States */
const systemMetrics = $derived($initialized ? [...globalMetrics.filter((gm) => gm?.availability.find((av) => av.cluster == cluster))] : []);
const presetSystemUnits = $derived(loadUnits(systemMetrics));
let selectedMetric = $derived.by(() => {
let configKey = `nodeOverview_selectedMetric`;
if (cluster) configKey += `:${cluster}`;
if (subCluster) configKey += `:${subCluster}`;
if ($initialized) {
if (ccconfig[configKey]) return ccconfig[configKey]
else if (systemMetrics.length !== 0) return systemMetrics[0].name
}
return ""
});
let selectedMetrics = $derived.by(() => {
let configKey = `nodeList_selectedMetrics`;
if (cluster) configKey += `:${cluster}`;
if (subCluster) configKey += `:${subCluster}`;
if ($initialized) {
if (ccconfig[configKey]) return ccconfig[configKey]
else if (systemMetrics.length >= 3) return [systemMetrics[0].name, systemMetrics[1].name, systemMetrics[2].name]
}
return []
});
/* Effects */
$effect(() => {
if (displayNodeOverview) {
updateOverviewMetric(selectedMetric)
}
});
/* 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
function updateHostnameFilter() {
if (timeoutId != null) clearTimeout(timeoutId);
timeoutId = setTimeout(function () {
hostnameFilter = pendingHostnameFilter;
}, 500);
};
function updateOverviewMetric(newMetric) {
let configKey = `nodeOverview_selectedMetric`;
if (cluster) configKey += `:${cluster}`;
if (subCluster) configKey += `:${subCluster}`;
updateConfigurationMutation({
name: configKey,
value: JSON.stringify(newMetric),
}).subscribe((res) => {
if (res.fetching === false && res.error) {
throw res.error;
}
});
};
const updateConfigurationMutation = ({ name, value }) => {
return mutationStore({
client: client,
query: gql`
mutation ($name: String!, $value: String!) {
updateConfiguration(name: $name, value: $value)
}
`,
variables: { name, value },
});
};
</script>
<!-- ROW1: Tools-->
<Row cols={{ xs: 2, lg: !displayNodeOverview ? (resampleConfig ? 6 : 5) : 5 }} class="mb-3">
{#if $initq?.data}
<!-- List Metric Select Col-->
{#if !displayNodeOverview}
<Col>
<InputGroup>
<InputGroupText><Icon name="graph-up" /></InputGroupText>
<InputGroupText class="text-capitalize">Metrics</InputGroupText>
<Button
outline
color="primary"
onclick={() => (isMetricsSelectionOpen = true)}
>
{selectedMetrics.length} selected
</Button>
</InputGroup>
</Col>
{#if resampleConfig}
<Col>
<InputGroup>
<InputGroupText><Icon name="plus-slash-minus" /></InputGroupText>
<InputGroupText>Resolution</InputGroupText>
<Input type="select" bind:value={selectedResolution}>
{#each resampleResolutions as res}
<option value={res}
>{res} sec</option
>
{/each}
</Input>
</InputGroup>
</Col>
{/if}
{/if}
<!-- Node Col-->
<Col class="mt-2 mt-lg-0">
<InputGroup>
<InputGroupText><Icon name="hdd" /></InputGroupText>
<InputGroupText>Node(s)</InputGroupText>
<Input
placeholder="Filter hostname ..."
type="text"
bind:value={pendingHostnameFilter}
oninput={updateHostnameFilter}
/>
</InputGroup>
</Col>
<!-- State Col-->
<Col class="mt-2 mt-lg-0">
<InputGroup>
<InputGroupText><Icon name="clipboard2-pulse" /></InputGroupText>
<InputGroupText>State</InputGroupText>
<Input type="select" bind:value={hoststateFilter}>
{#each stateOptions as so}
<option value={so}>{so.charAt(0).toUpperCase() + so.slice(1)}</option>
{/each}
</Input>
</InputGroup>
</Col>
<!-- Range Col-->
<Col>
<TimeSelection
presetFrom={from}
presetTo={to}
applyTime={(newFrom, newTo) => {
from = newFrom;
to = newTo;
}}
/>
</Col>
<!-- Overview Metric Col-->
{#if displayNodeOverview}
<Col class="mt-2 mt-lg-0">
<InputGroup>
<InputGroupText><Icon name="graph-up" /></InputGroupText>
<InputGroupText>Metric</InputGroupText>
<Input type="select" bind:value={selectedMetric}>
{#each systemMetrics as metric (metric.name)}
<option value={metric.name}
>{metric.name} {presetSystemUnits[metric.name] ? "("+presetSystemUnits[metric.name]+")" : ""}</option
>
{:else}
<option disabled>No available options</option>
{/each}
</Input>
</InputGroup>
</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>
<!-- ROW2: Content-->
{#if displayType !== "OVERVIEW" && displayType !== "LIST"}
<Row>
<Col>
<Card body color="danger">Unknown displayList type! </Card>
</Col>
</Row>
{:else}
{#if displayNodeOverview}
<!-- ROW2-1: Node Overview (Grid Included)-->
<NodeOverview {cluster} {ccconfig} {selectedMetric} {from} {to} {hostnameFilter} {hoststateFilter}/>
{:else}
<!-- ROW2-2: Node List (Grid Included)-->
<NodeList {cluster} {subCluster} {ccconfig} {selectedMetrics} {selectedResolution} {hostnameFilter} {hoststateFilter} {from} {to} {presetSystemUnits}/>
{/if}
{/if}
{#if !displayNodeOverview}
<MetricSelection
bind:isOpen={isMetricsSelectionOpen}
presetMetrics={selectedMetrics}
{cluster}
{subCluster}
configName="nodeList_selectedMetrics"
applyMetrics={(newMetrics) =>
selectedMetrics = [...newMetrics]
}
/>
{/if}