Files
cc-backend/web/frontend/src/generic/joblist/JobListRow.svelte

253 lines
8.1 KiB
Svelte

<!--
@component Data row for a single job displaying metric plots
Properties:
- `job Object`: The job object (GraphQL.Job)
- `metrics [String]`: Currently selected metrics
- `plotWidth Number`: Width of the sub-components
- `plotHeight Number?`: Height of the sub-components [Default: 275]
- `showFootprint Bool`: Display of footprint component for job [Default: false]
- `previousSelect Bool`: The latest job select state for job comparison [Default: false]
- `triggerMetricRefresh Bool?`: If changed to true from upstream, will trigger metric query [Default: false]
- `selectJob Func`: The callback function to select a job for comparison
- `unselectJob Func`: The callback function to unselect a job from comparison
- `globalMetrics [Obj]`: Includes the backend supplied availabilities for cluster and subCluster
- `clusterInfos [Obj]`: Includes the backend supplied cluster topology
- `resampleConfig [Obj]`: Includes the backend supplied resampling info
-->
<script>
import { queryStore, gql, getContextClient } from "@urql/svelte";
import { Card, Spinner } from "@sveltestrap/sveltestrap";
import { maxScope, checkMetricAvailability } from "../utils.js";
import JobInfo from "./JobInfo.svelte";
import MetricPlot from "../plots/MetricPlot.svelte";
import JobFootprint from "../helper/JobFootprint.svelte";
/* Svelte 5 Props */
let {
job,
metrics,
plotWidth,
plotHeight = 275,
showFootprint = false,
previousSelect = false,
triggerMetricRefresh = false,
selectJob,
unselectJob,
globalMetrics,
clusterInfos,
resampleConfig
} = $props();
/* Const Init */
const client = getContextClient();
const query = gql`
query ($id: ID!, $metrics: [String!]!, $scopes: [MetricScope!]!, $selectedResolution: Int) {
jobMetrics(id: $id, metrics: $metrics, scopes: $scopes, resolution: $selectedResolution) {
name
scope
metric {
unit {
prefix
base
}
timestep
statisticsSeries {
min
mean
median
max
}
series {
hostname
id
data
statistics {
min
avg
max
}
}
}
}
}
`;
/* State Init */
let zoomStates = $state({});
let thresholdStates = $state({});
/* Derived */
const resampleDefault = $derived(resampleConfig ? Math.max(...resampleConfig.resolutions) : 0);
const jobId = $derived(job?.id);
const scopes = $derived.by(() => {
if (job.numNodes == 1) {
if (job.numAcc >= 1) return ["core", "accelerator"];
else return ["core"];
} else {
return ["node"];
};
});
let selectedResolution = $derived(resampleDefault);
let isSelected = $derived(previousSelect);
let metricsQuery = $derived(queryStore({
client: client,
query: query,
variables: { id: jobId, metrics, scopes, selectedResolution },
})
);
const refinedData = $derived($metricsQuery?.data?.jobMetrics ? sortAndSelectScope(metrics, $metricsQuery.data.jobMetrics) : []);
/* Effects */
$effect(() => {
if (job.state === 'running' && triggerMetricRefresh === true) {
refreshMetrics();
}
});
$effect(() => {
if (isSelected == true && previousSelect == false) {
selectJob(jobId)
} else if (isSelected == false && previousSelect == true) {
unselectJob(jobId)
}
});
/* Functions */
function handleZoom(detail, metric) {
// Buffer last zoom state to allow seamless zoom on rerender
// console.log('Update zoomState for/with:', metric, {...detail.lastZoomState})
zoomStates[metric] = detail?.lastZoomState ? {...detail.lastZoomState} : null;
// Handle to correctly reset on summed metric scope change
// console.log('Update thresholdState for/with:', metric, detail.lastThreshold)
thresholdStates[metric] = detail?.lastThreshold ? detail.lastThreshold : null;
// Triggers GQL
if (detail?.newRes) {
// console.log('Update selectedResolution for/with:', metric, detail.newRes)
selectedResolution = detail.newRes;
}
}
function refreshMetrics() {
metricsQuery = queryStore({
client: client,
query: query,
variables: { id: jobId, metrics, scopes, selectedResolution },
// requestPolicy: 'network-only' // use default cache-first for refresh
});
}
function sortAndSelectScope(metricList = [], jobMetrics = []) {
const pendingData = [];
metricList.forEach((metricName) => {
const pendingMetric = {
name: metricName,
availability: checkMetricAvailability(
globalMetrics,
metricName,
job.cluster,
job.subCluster,
),
data: null
};
const scopesData = jobMetrics.filter((jobMetric) => jobMetric.name == metricName)
if (scopesData.length > 0) pendingMetric.data = selectScope(scopesData)
pendingData.push(pendingMetric)
});
return pendingData;
};
const selectScope = (jobMetrics) =>
jobMetrics.reduce(
(a, b) =>
maxScope([a.scope, b.scope]) == a.scope
? job.numNodes > 1
? a
: b
: job.numNodes > 1
? b
: a,
jobMetrics[0],
);
</script>
<tr>
<td>
<JobInfo {job} bind:isSelected showJobSelect/>
</td>
{#if job.monitoringStatus == 0 || job.monitoringStatus == 2}
<td colspan={metrics.length}>
<Card body color="warning">Not monitored or archiving failed</Card>
</td>
{:else if $metricsQuery.fetching}
<td colspan={metrics.length} style="text-align: center;">
<Spinner secondary />
</td>
{:else if $metricsQuery.error}
<td colspan={metrics.length}>
<Card body color="danger" class="mb-3">
{$metricsQuery.error.message.length > 500
? $metricsQuery.error.message.substring(0, 499) + "..."
: $metricsQuery.error.message}
</Card>
</td>
{:else}
{#if showFootprint}
<td>
<JobFootprint
{job}
width={plotWidth}
height="{plotHeight}px"
displayTitle={false}
/>
</td>
{/if}
{#each refinedData as metric, i (metric?.name || i)}
<td>
{#if metric?.availability == "none"}
<Card body class="mx-2" color="light">
<p>No dataset(s) returned for <b>{metrics[i]}</b></p>
<p class="mb-1">Metric is not configured for cluster <b>{job.cluster}</b>.</p>
</Card>
{:else if metric?.availability == "disabled"}
<Card body class="mx-2" color="info">
<p>No dataset(s) returned for <b>{metrics[i]}</b></p>
<p class="mb-1">Metric has been disabled for subcluster <b>{job.subCluster}</b>.</p>
</Card>
{:else if metric?.data}
<MetricPlot
onZoom={(detail) => handleZoom(detail, metric.data.name)}
height={plotHeight}
timestep={metric.data.metric.timestep}
scope={metric.data.scope}
series={metric.data.metric.series}
statisticsSeries={metric.data.metric.statisticsSeries}
metric={metric.data.name}
cluster={clusterInfos.find((c) => c.name == job.cluster)}
subCluster={job.subCluster}
isShared={job.shared != "none"}
numhwthreads={job.numHWThreads}
numaccs={job.numAcc}
zoomState={zoomStates[metric.data.name] || null}
thresholdState={thresholdStates[metric.data.name] || null}
/>
{:else}
<Card body class="mx-2" color="warning">
<p>No dataset(s) returned for <b>{metrics[i]}</b></p>
<p class="mb-1">Metric or host was not found in metric store for cluster <b>{job.cluster}</b>:</p>
<p class="mb-1">Identical messages in <i>{metrics[i]} column</i>: Metric not found.</p>
<p class="mb-1">Identical messages in <i>job {job.jobId} row</i>: Host not found.</p>
</Card>
{/if}
</td>
{:else}
<td>
<Card body class="mx-2">No metrics selected for display.</Card>
</td>
{/each}
{/if}
</tr>