include feedback on nodeListView

- display names of users and projects
- stacked metricPlot for statsSeries
This commit is contained in:
Christoph Kluge 2025-01-17 13:13:00 +01:00
parent 5c2c493c56
commit d0580592be
6 changed files with 188 additions and 134 deletions

View File

@ -423,8 +423,8 @@ func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.Job
// NodeMetrics is the resolver for the nodeMetrics field.
func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) {
user := repository.GetUserFromContext(ctx)
if user != nil && !user.HasRole(schema.RoleAdmin) {
return nil, errors.New("you need to be an administrator for this query")
if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
return nil, errors.New("you need to be administrator or support staff for this query")
}
if metrics == nil {
@ -479,8 +479,8 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
}
user := repository.GetUserFromContext(ctx)
if user != nil && !user.HasRole(schema.RoleAdmin) {
return nil, errors.New("you need to be an administrator for this query")
if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
return nil, errors.New("you need to be administrator or support staff for this query")
}
if metrics == nil {

View File

@ -287,11 +287,11 @@ func LoadNodeListData(
}
// NOTE: New StatsSeries will always be calculated as 'min/median/max'
const maxSeriesSize int = 15
const maxSeriesSize int = 8
for _, jd := range data {
for _, scopes := range jd {
for _, jm := range scopes {
if jm.StatisticsSeries != nil || len(jm.Series) <= maxSeriesSize {
if jm.StatisticsSeries != nil || len(jm.Series) < maxSeriesSize {
continue
}
jm.AddStatisticsSeries()

View File

@ -94,7 +94,7 @@
},
{
title: "Nodes",
requiredRole: roles.admin,
requiredRole: roles.support,
href: "/monitoring/systems/",
icon: "hdd-rack",
perCluster: true,
@ -102,19 +102,19 @@
menu: "Info",
},
{
title: "Status",
requiredRole: roles.admin,
href: "/monitoring/status/",
icon: "clipboard-data",
title: "Analysis",
requiredRole: roles.support,
href: "/monitoring/analysis/",
icon: "graph-up",
perCluster: true,
listOptions: false,
menu: "Info",
},
{
title: "Analysis",
requiredRole: roles.support,
href: "/monitoring/analysis/",
icon: "graph-up",
title: "Status",
requiredRole: roles.admin,
href: "/monitoring/status/",
icon: "clipboard-data",
perCluster: true,
listOptions: false,
menu: "Info",

View File

@ -9,7 +9,7 @@
- `height Number?`: The plot height [Default: 300]
- `timestep Number`: The timestep used for X-axis rendering
- `series [GraphQL.Series]`: The metric data object
- `useStatsSeries Bool?`: If this plot uses the statistics Min/Max/Median representation; automatically set to according bool [Default: null]
- `useStatsSeries Bool?`: If this plot uses the statistics Min/Max/Median representation; automatically set to according bool [Default: false]
- `statisticsSeries [GraphQL.StatisticsSeries]?`: Min/Max/Median representation of metric data [Default: null]
- `cluster String`: Cluster name of the parent job / data
- `subCluster String`: Name of the subCluster of the parent job
@ -128,7 +128,7 @@
export let height = 300;
export let timestep;
export let series;
export let useStatsSeries = null;
export let useStatsSeries = false;
export let statisticsSeries = null;
export let cluster = "";
export let subCluster;
@ -139,10 +139,9 @@
export let zoomState = null;
export let thresholdState = null;
if (useStatsSeries == null) useStatsSeries = statisticsSeries != null;
if (useStatsSeries == false && series == null) useStatsSeries = true;
if (!useStatsSeries && statisticsSeries != null) useStatsSeries = true;
const usesMeanStatsSeries = (useStatsSeries?.mean && statisticsSeries.mean.length != 0)
const usesMeanStatsSeries = (statisticsSeries?.mean && statisticsSeries.mean.length != 0)
const dispatch = createEventDispatcher();
const subClusterTopology = getContext("getHardwareTopology")(cluster, subCluster);
const metricConfig = getContext("getMetricConfig")(cluster, subCluster, metric);
@ -205,11 +204,10 @@
// conditional hide series color markers:
if (
useStatsSeries === true || // Min/Max/Median Self-Explanatory
useStatsSeries || // Min/Max/Median Self-Explanatory
dataSize === 1 || // Only one Y-Dataseries
dataSize > 6
dataSize > 8 // More than 8 Y-Dataseries
) {
// More than 6 Y-Dataseries
const idents = legendEl.querySelectorAll(".u-marker");
for (let i = 0; i < idents.length; i++)
idents[i].style.display = "none";
@ -240,7 +238,7 @@
"translate(" + (left - width - 15) + "px, " + (top + 15) + "px)";
}
if (dataSize <= 12 || useStatsSeries === true) {
if (dataSize <= 12 || useStatsSeries) {
return {
hooks: {
init: init,
@ -432,13 +430,13 @@
u.ctx.save();
u.ctx.textAlign = "start"; // 'end'
u.ctx.fillStyle = "black";
u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + 10);
u.ctx.fillText(textl, u.bbox.left + 10, u.bbox.top + (forNode ? 0 : 10));
u.ctx.textAlign = "end";
u.ctx.fillStyle = "black";
u.ctx.fillText(
textr,
u.bbox.left + u.bbox.width - 10,
u.bbox.top + 10,
u.bbox.top + (forNode ? 0 : 10),
);
// u.ctx.fillText(text, u.bbox.left + u.bbox.width - 10, u.bbox.top + u.bbox.height - 10) // Recipe for bottom right
@ -496,10 +494,12 @@
},
legend: {
// Display legend until max 12 Y-dataseries
show: series.length <= 12 || useStatsSeries === true ? true : false,
live: series.length <= 12 || useStatsSeries === true ? true : false,
show: series.length <= 12 || useStatsSeries,
live: series.length <= 12 || useStatsSeries,
},
cursor: { drag: { x: true, y: true } },
cursor: {
drag: { x: true, y: true },
}
};
// RENDER HANDLING

View File

@ -45,6 +45,11 @@
$paging: PageRequest!
) {
jobs(filter: $filter, order: $sorting, page: $paging) {
items {
user
project
exclusive
}
count
}
}
@ -61,6 +66,13 @@
variables: { paging, sorting, filter },
});
let userList;
let projectList;
$: if ($nodeJobsData?.data) {
userList = Array.from(new Set($nodeJobsData.data.jobs.items.map((j) => j.user))).sort((a, b) => a.localeCompare(b));
projectList = Array.from(new Set($nodeJobsData.data.jobs.items.map((j) => j.project))).sort((a, b) => a.localeCompare(b));
}
</script>
<Card class="pb-3">
@ -83,113 +95,124 @@
{#if $nodeJobsData.fetching}
<Spinner />
{:else if $nodeJobsData.data}
<p>
{#if healthWarn}
<InputGroup>
<InputGroupText>
<Icon name="exclamation-circle"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="danger" disabled>
Unhealthy
</Button>
</InputGroup>
{:else if metricWarn}
<InputGroup>
<InputGroupText>
<Icon name="circle-half"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="warning" disabled>
Missing Metric
</Button>
</InputGroup>
{:else if $nodeJobsData.data.jobs.count > 0}
<InputGroup>
<InputGroupText>
<Icon name="circle-fill"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="success" disabled>
Allocated
</Button>
</InputGroup>
{:else}
<InputGroup>
<InputGroupText>
<Icon name="circle"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="secondary" disabled>
Idle
</Button>
</InputGroup>
{/if}
</p>
<hr class="mt-0 mb-3"/>
<p>
{#if $nodeJobsData.data.jobs.count > 0}
<InputGroup size="sm" class="justify-content-between">
<InputGroupText>
<Icon name="activity"/>
</InputGroupText>
<InputGroupText>
Activity
</InputGroupText>
<Input class="flex-grow-1" style="background-color: white;" type="text" value="{$nodeJobsData.data.jobs.count} Jobs" disabled />
<a title="Show jobs running on this node" href="/monitoring/jobs/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-primary" role="button" aria-disabled="true" >
<Icon name="view-list" />
List
</a>
</InputGroup>
{:else}
<InputGroup size="sm" class="justify-content-between">
<InputGroupText>
<Icon name="activity" />
</InputGroupText>
<InputGroupText>
Activity
</InputGroupText>
<Input class="flex-grow-1" type="text" style="background-color: white;" value="No running jobs." disabled />
</InputGroup>
{/if}
</p>
<p>
<InputGroup size="sm" class="justify-content-between">
{#if healthWarn}
<InputGroup>
<InputGroupText>
<Icon name="people"/>
<Icon name="exclamation-circle"/>
</InputGroupText>
<InputGroupText class="flex-fill">
Show Users
</InputGroupText>
<a title="Show users active on this node" href="/monitoring/users/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-primary" role="button" aria-disabled="true" >
<Icon name="view-list" />
List
</a>
</InputGroup>
</p>
<p>
<InputGroup size="sm" class="justify-content-between">
<InputGroupText>
<Icon name="journals"/>
Status
</InputGroupText>
<InputGroupText class="flex-fill">
Show Projects
</InputGroupText>
<a title="Show projects active on this node" href="/monitoring/projects/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-primary" role="button" aria-disabled="true" >
<Icon name="view-list" />
List
</a>
<Button color="danger" disabled>
Unhealthy
</Button>
</InputGroup>
</p>
{:else if metricWarn}
<InputGroup>
<InputGroupText>
<Icon name="info-circle"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="warning" disabled>
Missing Metric
</Button>
</InputGroup>
{:else if $nodeJobsData.data.jobs.count == 1 && $nodeJobsData.data.jobs.items[0].exclusive}
<InputGroup>
<InputGroupText>
<Icon name="circle-fill"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="success" disabled>
Exclusive
</Button>
</InputGroup>
{:else if $nodeJobsData.data.jobs.count >= 1 && !$nodeJobsData.data.jobs.items[0].exclusive}
<InputGroup>
<InputGroupText>
<Icon name="circle-half"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="success" disabled>
Shared
</Button>
</InputGroup>
{:else}
<InputGroup>
<InputGroupText>
<Icon name="circle"/>
</InputGroupText>
<InputGroupText>
Status
</InputGroupText>
<Button color="secondary" disabled>
Idle
</Button>
</InputGroup>
{/if}
<hr class="my-3"/>
<!-- JOBS -->
<InputGroup size="sm" class="justify-content-between mb-3">
<InputGroupText>
<Icon name="activity"/>
</InputGroupText>
<InputGroupText class="justify-content-center" style="width: 4.4rem;">
Activity
</InputGroupText>
<Input class="flex-grow-1" style="background-color: white;" type="text" value="{$nodeJobsData?.data?.jobs?.count || 0} Job{($nodeJobsData?.data?.jobs?.count == 1) ? '': 's'}" disabled />
<a title="Show jobs running on this node" href="/monitoring/jobs/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-primary" role="button" aria-disabled="true" >
<Icon name="view-list" />
List
</a>
</InputGroup>
<!-- USERS -->
<InputGroup size="sm" class="justify-content-between {(userList?.length > 0) ? 'mb-1' : 'mb-3'}">
<InputGroupText>
<Icon name="people"/>
</InputGroupText>
<InputGroupText class="justify-content-center" style="width: 4.4rem;">
Users
</InputGroupText>
<Input class="flex-grow-1" style="background-color: white;" type="text" value="{userList?.length || 0} User{(userList?.length == 1) ? '': 's'}" disabled />
<a title="Show users active on this node" href="/monitoring/users/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-primary" role="button" aria-disabled="true" >
<Icon name="view-list" />
List
</a>
</InputGroup>
{#if userList?.length > 0}
<Card class="mb-3">
<div class="p-1">
{userList.join(", ")}
</div>
</Card>
{/if}
<!-- PROJECTS -->
<InputGroup size="sm" class="justify-content-between {(projectList?.length > 0) ? 'mb-1' : 'mb-3'}">
<InputGroupText>
<Icon name="journals"/>
</InputGroupText>
<InputGroupText class="justify-content-center" style="width: 4.4rem;">
Projects
</InputGroupText>
<Input class="flex-grow-1" style="background-color: white;" type="text" value="{projectList?.length || 0} Project{(projectList?.length == 1) ? '': 's'}" disabled />
<a title="Show projects active on this node" href="/monitoring/projects/?cluster={cluster}&state=running&node={hostname}" target="_blank" class="btn btn-outline-primary" role="button" aria-disabled="true" >
<Icon name="view-list" />
List
</a>
</InputGroup>
{#if projectList?.length > 0}
<Card>
<div class="p-1">
{projectList.join(", ")}
</div>
</Card>
{/if}
{/if}
</CardBody>
</Card>

View File

@ -46,13 +46,21 @@
return scopedNodeMetric;
}
});
let refinedData;
let dataHealth;
$: if (nodeData?.metrics) {
refinedData = sortAndSelectScope(nodeData?.metrics)
// Check data for series, skip disabled
dataHealth = refinedData.filter((rd) => rd.disabled === false).map((enabled) => (enabled.data.metric.series.length > 0))
}
</script>
<tr>
<td>
<NodeInfo {cluster} subCluster={nodeData.subCluster} hostname={nodeData.host} dataHealth={nodeData?.metrics.map((m) => (m.metric.series.length > 0))}/>
<NodeInfo {cluster} subCluster={nodeData.subCluster} hostname={nodeData.host} {dataHealth}/>
</td>
{#each sortAndSelectScope(nodeData?.metrics) as metricData (metricData.data.name)}
{#each refinedData as metricData (metricData.data.name)}
<td>
{#if metricData?.disabled}
<Card body class="mx-3" color="info"
@ -60,7 +68,7 @@
>{metricData.data.name}:{nodeData.subCluster}</code
></Card
>
{:else}
{:else if !!metricData.data?.metric.statisticsSeries}
<!-- "No Data"-Warning included in MetricPlot-Component -->
<MetricPlot
{cluster}
@ -71,6 +79,29 @@
series={metricData.data.metric.series}
statisticsSeries={metricData.data?.metric.statisticsSeries}
useStatsSeries={!!metricData.data?.metric.statisticsSeries}
height={175}
forNode
/>
<div class="my-2"/>
<MetricPlot
{cluster}
subCluster={nodeData.subCluster}
metric={metricData.data.name}
scope={metricData.data.scope}
timestep={metricData.data.metric.timestep}
series={metricData.data.metric.series}
height={175}
forNode
/>
{:else}
<MetricPlot
{cluster}
subCluster={nodeData.subCluster}
metric={metricData.data.name}
scope={metricData.data.scope}
timestep={metricData.data.metric.timestep}
series={metricData.data.metric.series}
height={375}
forNode
/>
{/if}