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. // 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) { 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) user := repository.GetUserFromContext(ctx)
if user != nil && !user.HasRole(schema.RoleAdmin) { if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
return nil, errors.New("you need to be an administrator for this query") return nil, errors.New("you need to be administrator or support staff for this query")
} }
if metrics == nil { if metrics == nil {
@ -479,8 +479,8 @@ func (r *queryResolver) NodeMetricsList(ctx context.Context, cluster string, sub
} }
user := repository.GetUserFromContext(ctx) user := repository.GetUserFromContext(ctx)
if user != nil && !user.HasRole(schema.RoleAdmin) { if user != nil && !user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) {
return nil, errors.New("you need to be an administrator for this query") return nil, errors.New("you need to be administrator or support staff for this query")
} }
if metrics == nil { if metrics == nil {

View File

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

View File

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

View File

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

View File

@ -45,6 +45,11 @@
$paging: PageRequest! $paging: PageRequest!
) { ) {
jobs(filter: $filter, order: $sorting, page: $paging) { jobs(filter: $filter, order: $sorting, page: $paging) {
items {
user
project
exclusive
}
count count
} }
} }
@ -61,6 +66,13 @@
variables: { paging, sorting, filter }, 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> </script>
<Card class="pb-3"> <Card class="pb-3">
@ -83,113 +95,124 @@
{#if $nodeJobsData.fetching} {#if $nodeJobsData.fetching}
<Spinner /> <Spinner />
{:else if $nodeJobsData.data} {:else if $nodeJobsData.data}
<p> {#if healthWarn}
{#if healthWarn} <InputGroup>
<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">
<InputGroupText> <InputGroupText>
<Icon name="people"/> <Icon name="exclamation-circle"/>
</InputGroupText> </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> <InputGroupText>
<Icon name="journals"/> Status
</InputGroupText> </InputGroupText>
<InputGroupText class="flex-fill"> <Button color="danger" disabled>
Show Projects Unhealthy
</InputGroupText> </Button>
<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> </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} {/if}
</CardBody> </CardBody>
</Card> </Card>

View File

@ -46,13 +46,21 @@
return scopedNodeMetric; 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> </script>
<tr> <tr>
<td> <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> </td>
{#each sortAndSelectScope(nodeData?.metrics) as metricData (metricData.data.name)} {#each refinedData as metricData (metricData.data.name)}
<td> <td>
{#if metricData?.disabled} {#if metricData?.disabled}
<Card body class="mx-3" color="info" <Card body class="mx-3" color="info"
@ -60,7 +68,7 @@
>{metricData.data.name}:{nodeData.subCluster}</code >{metricData.data.name}:{nodeData.subCluster}</code
></Card ></Card
> >
{:else} {:else if !!metricData.data?.metric.statisticsSeries}
<!-- "No Data"-Warning included in MetricPlot-Component --> <!-- "No Data"-Warning included in MetricPlot-Component -->
<MetricPlot <MetricPlot
{cluster} {cluster}
@ -71,6 +79,29 @@
series={metricData.data.metric.series} series={metricData.data.metric.series}
statisticsSeries={metricData.data?.metric.statisticsSeries} statisticsSeries={metricData.data?.metric.statisticsSeries}
useStatsSeries={!!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 forNode
/> />
{/if} {/if}