Merge branch 'dev' of github.com:ClusterCockpit/cc-backend into dev

This commit is contained in:
2026-03-04 15:04:56 +01:00
9 changed files with 188 additions and 58 deletions

View File

@@ -1,3 +1,4 @@
version: 2
before: before:
hooks: hooks:
- go mod tidy - go mod tidy
@@ -34,6 +35,19 @@ builds:
main: ./tools/archive-manager main: ./tools/archive-manager
tags: tags:
- static_build - static_build
- env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
goamd64:
- v3
id: "archive-migration"
binary: archive-migration
main: ./tools/archive-migration
tags:
- static_build
- env: - env:
- CGO_ENABLED=0 - CGO_ENABLED=0
goos: goos:
@@ -48,7 +62,7 @@ builds:
tags: tags:
- static_build - static_build
archives: archives:
- format: tar.gz - formats: tar.gz
# this name template makes the OS and Arch compatible with the results of uname. # this name template makes the OS and Arch compatible with the results of uname.
name_template: >- name_template: >-
{{ .ProjectName }}_ {{ .ProjectName }}_
@@ -59,7 +73,7 @@ archives:
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
snapshot: snapshot:
name_template: "{{ incpatch .Version }}-next" version_template: "{{ incpatch .Version }}-next"
changelog: changelog:
sort: asc sort: asc
filters: filters:
@@ -87,7 +101,7 @@ changelog:
release: release:
draft: false draft: false
footer: | footer: |
Supports job archive version 2 and database version 8. Supports job archive version 3 and database version 10.
Please check out the [Release Notes](https://github.com/ClusterCockpit/cc-backend/blob/master/ReleaseNotes.md) for further details on breaking changes. Please check out the [Release Notes](https://github.com/ClusterCockpit/cc-backend/blob/master/ReleaseNotes.md) for further details on breaking changes.
# vim: set ts=2 sw=2 tw=0 fo=cnqoj # vim: set ts=2 sw=2 tw=0 fo=cnqoj

View File

@@ -405,6 +405,17 @@ func buildFilterPresets(query url.Values) map[string]any {
if query.Get("energy") != "" { if query.Get("energy") != "" {
parts := strings.Split(query.Get("energy"), "-") parts := strings.Split(query.Get("energy"), "-")
if len(parts) == 2 { if len(parts) == 2 {
if parts[0] == "lessthan" {
lt, lte := strconv.Atoi(parts[1])
if lte == nil {
filterPresets["energy"] = map[string]int{"from": 1, "to": lt}
}
} else if parts[0] == "morethan" {
mt, mte := strconv.Atoi(parts[1])
if mte == nil {
filterPresets["energy"] = map[string]int{"from": mt, "to": 0}
}
} else {
a, e1 := strconv.Atoi(parts[0]) a, e1 := strconv.Atoi(parts[0])
b, e2 := strconv.Atoi(parts[1]) b, e2 := strconv.Atoi(parts[1])
if e1 == nil && e2 == nil { if e1 == nil && e2 == nil {
@@ -412,11 +423,33 @@ func buildFilterPresets(query url.Values) map[string]any {
} }
} }
} }
}
if len(query["stat"]) != 0 { if len(query["stat"]) != 0 {
statList := make([]map[string]any, 0) statList := make([]map[string]any, 0)
for _, statEntry := range query["stat"] { for _, statEntry := range query["stat"] {
parts := strings.Split(statEntry, "-") parts := strings.Split(statEntry, "-")
if len(parts) == 3 { // Metric Footprint Stat Field, from - to if len(parts) == 3 { // Metric Footprint Stat Field, from - to
if parts[1] == "lessthan" {
lt, lte := strconv.ParseInt(parts[2], 10, 64)
if lte == nil {
statEntry := map[string]any{
"field": parts[0],
"from": 1,
"to": lt,
}
statList = append(statList, statEntry)
}
} else if parts[1] == "morethan" {
mt, mte := strconv.ParseInt(parts[2], 10, 64)
if mte == nil {
statEntry := map[string]any{
"field": parts[0],
"from": mt,
"to": 0,
}
statList = append(statList, statEntry)
}
} else {
a, e1 := strconv.ParseInt(parts[1], 10, 64) a, e1 := strconv.ParseInt(parts[1], 10, 64)
b, e2 := strconv.ParseInt(parts[2], 10, 64) b, e2 := strconv.ParseInt(parts[2], 10, 64)
if e1 == nil && e2 == nil { if e1 == nil && e2 == nil {
@@ -429,6 +462,7 @@ func buildFilterPresets(query url.Values) map[string]any {
} }
} }
} }
}
filterPresets["stats"] = statList filterPresets["stats"] = statList
} }
return filterPresets return filterPresets

View File

@@ -206,7 +206,7 @@
items.push({ duration: { to: filters.duration.lessThan, from: 0 } }); items.push({ duration: { to: filters.duration.lessThan, from: 0 } });
if (filters.duration.moreThan) if (filters.duration.moreThan)
items.push({ duration: { to: 0, from: filters.duration.moreThan } }); items.push({ duration: { to: 0, from: filters.duration.moreThan } });
if (filters.energy.from || filters.energy.to) if (filters.energy.from != null || filters.energy.to != null)
items.push({ items.push({
energy: { from: filters.energy.from, to: filters.energy.to }, energy: { from: filters.energy.from, to: filters.energy.to },
}); });
@@ -301,11 +301,20 @@
if (filters.node) opts.push(`node=${filters.node}`); if (filters.node) opts.push(`node=${filters.node}`);
if (filters.node && filters.nodeMatch != "eq") // "eq" is default-case if (filters.node && filters.nodeMatch != "eq") // "eq" is default-case
opts.push(`nodeMatch=${filters.nodeMatch}`); opts.push(`nodeMatch=${filters.nodeMatch}`);
if (filters.energy.from && filters.energy.to) if (filters.energy.from > 1 && filters.energy.to > 0)
opts.push(`energy=${filters.energy.from}-${filters.energy.to}`); opts.push(`energy=${filters.energy.from}-${filters.energy.to}`);
if (filters.stats.length != 0) else if (filters.energy.from > 1 && filters.energy.to == 0)
opts.push(`energy=morethan-${filters.energy.from}`);
else if (filters.energy.from == 1 && filters.energy.to > 0)
opts.push(`energy=lessthan-${filters.energy.to}`);
if (filters.stats.length > 0)
for (let stat of filters.stats) { for (let stat of filters.stats) {
if (stat.from > 1 && stat.to > 0)
opts.push(`stat=${stat.field}-${stat.from}-${stat.to}`); opts.push(`stat=${stat.field}-${stat.from}-${stat.to}`);
else if (stat.from > 1 && stat.to == 0)
opts.push(`stat=${stat.field}-morethan-${stat.from}`);
else if (stat.from == 1 && stat.to > 0)
opts.push(`stat=${stat.field}-lessthan-${stat.to}`);
} }
// Build && Return // Build && Return
if (opts.length == 0 && window.location.search.length <= 1) return; if (opts.length == 0 && window.location.search.length <= 1) return;
@@ -550,18 +559,36 @@
</Info> </Info>
{/if} {/if}
{#if filters.energy.from || filters.energy.to} {#if filters.energy.from > 1 && filters.energy.to > 0}
<Info icon="lightning-charge-fill" onclick={() => (isEnergyOpen = true)}> <Info icon="lightning-charge-fill" onclick={() => (isEnergyOpen = true)}>
Total Energy: {filters.energy.from} - {filters.energy.to} Total Energy: {filters.energy.from} - {filters.energy.to} kWh
</Info>
{:else if filters.energy.from > 1 && filters.energy.to == 0}
<Info icon="lightning-charge-fill" onclick={() => (isEnergyOpen = true)}>
Total Energy &ge;&nbsp;{filters.energy.from} kWh
</Info>
{:else if filters.energy.from == 1 && filters.energy.to > 0}
<Info icon="lightning-charge-fill" onclick={() => (isEnergyOpen = true)}>
Total Energy &le;&nbsp;{filters.energy.to} kWh
</Info> </Info>
{/if} {/if}
{#if filters.stats.length > 0} {#if filters.stats.length > 0}
{#each filters.stats as stat}
{#if stat.from > 1 && stat.to > 0}
<Info icon="bar-chart" onclick={() => (isStatsOpen = true)}> <Info icon="bar-chart" onclick={() => (isStatsOpen = true)}>
{filters.stats {stat.field}: {stat.from} - {stat.to} {stat.unit}
.map((stat) => `${stat.field}: ${stat.from} - ${stat.to}`) </Info>&thinsp;
.join(", ")} {:else if stat.from > 1 && stat.to == 0}
</Info> <Info icon="bar-chart" onclick={() => (isStatsOpen = true)}>
{stat.field} &ge;&nbsp;{stat.from} {stat.unit}
</Info>&thinsp;
{:else if stat.from == 1 && stat.to > 0}
<Info icon="bar-chart" onclick={() => (isStatsOpen = true)}>
{stat.field} &le;&nbsp;{stat.to} {stat.unit}
</Info>&thinsp;
{/if}
{/each}
{/if} {/if}
{/if} {/if}

View File

@@ -15,54 +15,90 @@
ModalBody, ModalBody,
ModalHeader, ModalHeader,
ModalFooter, ModalFooter,
Tooltip,
Icon
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import DoubleRangeSlider from "../select/DoubleRangeSlider.svelte"; import DoubleRangeSlider from "../select/DoubleRangeSlider.svelte";
/* Svelte 5 Props */ /* Svelte 5 Props */
let { let {
isOpen = $bindable(false), isOpen = $bindable(false),
presetEnergy = { presetEnergy = { from: null, to: null },
from: null,
to: null
},
setFilter, setFilter,
} = $props(); } = $props();
/* Const */
const minEnergyPreset = 1;
const maxEnergyPreset = 1000;
/* Derived */ /* Derived */
let energyState = $derived(presetEnergy); // Pending
let pendingEnergyState = $derived({
from: presetEnergy?.from ? presetEnergy.from : minEnergyPreset,
to: !(presetEnergy.to == null || presetEnergy.to == 0) ? presetEnergy.to : maxEnergyPreset,
});
// Changable
let energyState = $derived({
from: presetEnergy?.from ? presetEnergy.from : minEnergyPreset,
to: !(presetEnergy.to == null || presetEnergy.to == 0) ? presetEnergy.to : maxEnergyPreset,
});
const energyActive = $derived(!(JSON.stringify(energyState) === JSON.stringify({ from: minEnergyPreset, to: maxEnergyPreset })));
// Block Apply if null
const disableApply = $derived(energyState.from === null || energyState.to === null);
/* Function */
function setEnergy() {
if (energyActive) {
pendingEnergyState = {
from: energyState.from,
to: (energyState.to == maxEnergyPreset) ? 0 : energyState.to
};
} else {
pendingEnergyState = { from: null, to: null};
};
}
</script> </script>
<Modal {isOpen} toggle={() => (isOpen = !isOpen)}> <Modal {isOpen} toggle={() => (isOpen = !isOpen)}>
<ModalHeader>Filter based on energy</ModalHeader> <ModalHeader>Filter based on energy</ModalHeader>
<ModalBody> <ModalBody>
<div class="mb-3"> <div class="mb-3">
<div class="mb-0"><b>Total Job Energy (kWh)</b></div> <div class="mb-0">
<b>Total Job Energy (kWh)</b>
<Icon id="energy-info" style="cursor:help; padding-right: 10px;" size="sm" name="info-circle"/>
</div>
<Tooltip target={`energy-info`} placement="right">
Generalized Presets. Use input fields to change to higher values.
</Tooltip>
<DoubleRangeSlider <DoubleRangeSlider
changeRange={(detail) => { changeRange={(detail) => {
energyState.from = detail[0]; energyState.from = detail[0];
energyState.to = detail[1]; energyState.to = detail[1];
}} }}
sliderMin={0.0} sliderMin={minEnergyPreset}
sliderMax={1000.0} sliderMax={maxEnergyPreset}
fromPreset={energyState?.from? energyState.from : 0.0} fromPreset={energyState.from}
toPreset={energyState?.to? energyState.to : 1000.0} toPreset={energyState.to}
/> />
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button <Button
color="primary" color="primary"
disabled={disableApply}
onclick={() => { onclick={() => {
isOpen = false; isOpen = false;
setFilter({ energy: energyState }); setEnergy();
setFilter({ energy: pendingEnergyState });
}}>Close & Apply</Button }}>Close & Apply</Button
> >
<Button <Button
color="danger" color="danger"
onclick={() => { onclick={() => {
isOpen = false; isOpen = false;
energyState = {from: null, to: null}; pendingEnergyState = {from: null, to: null};
setFilter({ energy: energyState }); setFilter({ energy: pendingEnergyState });
}}>Reset</Button }}>Reset</Button
> >
<Button onclick={() => (isOpen = false)}>Close</Button> <Button onclick={() => (isOpen = false)}>Close</Button>

View File

@@ -20,7 +20,7 @@
} = $props(); } = $props();
</script> </script>
<Button class="mr-2 mb-1" outline color={modified ? "warning" : "primary"} {onclick}> <Button class="mb-1" outline color={modified ? "warning" : "primary"} {onclick}>
<Icon name={icon} /> <Icon name={icon} />
{#if children} {#if children}
<!-- Note: Ignore '@' Error in IDE --> <!-- Note: Ignore '@' Error in IDE -->

View File

@@ -262,7 +262,7 @@
<Icon id="numthreads-info" style="cursor:help; padding-right: 10px;" size="sm" name="info-circle"/> <Icon id="numthreads-info" style="cursor:help; padding-right: 10px;" size="sm" name="info-circle"/>
</div> </div>
<Tooltip target={`numthreads-info`} placement="right"> <Tooltip target={`numthreads-info`} placement="right">
Presets for a single node. Can be changed to higher values. Presets for a single node. Use input fields to change to higher values.
</Tooltip> </Tooltip>
<DoubleRangeSlider <DoubleRangeSlider
changeRange={(detail) => { changeRange={(detail) => {
@@ -282,7 +282,7 @@
<Icon id="numaccs-info" style="cursor:help; padding-right: 10px;" size="sm" name="info-circle"/> <Icon id="numaccs-info" style="cursor:help; padding-right: 10px;" size="sm" name="info-circle"/>
</div> </div>
<Tooltip target={`numaccs-info`} placement="right"> <Tooltip target={`numaccs-info`} placement="right">
Presets for a single node. Can be changed to higher values. Presets for a single node. Use input fields to change to higher values.
</Tooltip> </Tooltip>
<DoubleRangeSlider <DoubleRangeSlider
changeRange={(detail) => { changeRange={(detail) => {

View File

@@ -15,13 +15,15 @@
ModalBody, ModalBody,
ModalHeader, ModalHeader,
ModalFooter, ModalFooter,
Tooltip,
Icon
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import DoubleRangeSlider from "../select/DoubleRangeSlider.svelte"; import DoubleRangeSlider from "../select/DoubleRangeSlider.svelte";
/* Svelte 5 Props */ /* Svelte 5 Props */
let { let {
isOpen = $bindable(), isOpen = $bindable(),
presetStats, presetStats = [],
setFilter setFilter
} = $props(); } = $props();
@@ -29,10 +31,18 @@
const availableStats = $derived(getStatsItems(presetStats)); const availableStats = $derived(getStatsItems(presetStats));
/* Functions */ /* Functions */
function setRanges() {
for (let as of availableStats) {
if (as.enabled) {
as.to = (as.to == as.peak) ? 0 : as.to
}
};
}
function resetRanges() { function resetRanges() {
for (let as of availableStats) { for (let as of availableStats) {
as.enabled = false as.enabled = false
as.from = 0 as.from = 1
as.to = as.peak as.to = as.peak
}; };
} }
@@ -45,18 +55,24 @@
<ModalBody> <ModalBody>
{#each availableStats as aStat} {#each availableStats as aStat}
<div class="mb-3"> <div class="mb-3">
<div class="mb-0"><b>{aStat.text}</b></div> <div class="mb-0">
<b>{aStat.text} ({aStat.unit})</b>
<Icon id={`${aStat.metric}-info`} style="cursor:help; padding-right: 10px;" size="sm" name="info-circle"/>
</div>
<Tooltip target={`${aStat.metric}-info`} placement="right">
Peak Threshold Preset. Use input fields to change to higher values.
</Tooltip>
<DoubleRangeSlider <DoubleRangeSlider
changeRange={(detail) => { changeRange={(detail) => {
aStat.from = detail[0]; aStat.from = detail[0];
aStat.to = detail[1]; aStat.to = detail[1];
if (aStat.from == 0 && aStat.to == aStat.peak) { if (aStat.from == 1 && aStat.to == aStat.peak) {
aStat.enabled = false; aStat.enabled = false;
} else { } else {
aStat.enabled = true; aStat.enabled = true;
} }
}} }}
sliderMin={0.0} sliderMin={1}
sliderMax={aStat.peak} sliderMax={aStat.peak}
fromPreset={aStat.from} fromPreset={aStat.from}
toPreset={aStat.to} toPreset={aStat.to}
@@ -69,6 +85,7 @@
color="primary" color="primary"
onclick={() => { onclick={() => {
isOpen = false; isOpen = false;
setRanges();
setFilter({ stats: [...availableStats.filter((as) => as.enabled)] }); setFilter({ stats: [...availableStats.filter((as) => as.enabled)] });
}}>Close & Apply</Button }}>Close & Apply</Button
> >

View File

@@ -165,11 +165,11 @@
}} }}
/> />
{#if inputFieldFrom != "1" && inputFieldTo != sliderMax?.toString() } {#if inputFieldFrom != sliderMin?.toString() && inputFieldTo != sliderMax?.toString() }
<span>Selected: Range <b> {inputFieldFrom} </b> - <b> {inputFieldTo} </b></span> <span>Selected: Range <b> {inputFieldFrom} </b> - <b> {inputFieldTo} </b></span>
{:else if inputFieldFrom != "1" && inputFieldTo == sliderMax?.toString() } {:else if inputFieldFrom != sliderMin?.toString() && inputFieldTo == sliderMax?.toString() }
<span>Selected: More than <b> {inputFieldFrom} </b> </span> <span>Selected: More than <b> {inputFieldFrom} </b> </span>
{:else if inputFieldFrom == "1" && inputFieldTo != sliderMax?.toString() } {:else if inputFieldFrom == sliderMin?.toString() && inputFieldTo != sliderMax?.toString() }
<span>Selected: Less than <b> {inputFieldTo} </b></span> <span>Selected: Less than <b> {inputFieldTo} </b></span>
{:else} {:else}
<span><i>No Selection</i></span> <span><i>No Selection</i></span>

View File

@@ -341,26 +341,28 @@ export function getStatsItems(presetStats = []) {
if (gm?.footprint) { if (gm?.footprint) {
const mc = getMetricConfigDeep(gm.name, null, null) const mc = getMetricConfigDeep(gm.name, null, null)
if (mc) { if (mc) {
const presetEntry = presetStats.find((s) => s?.field === (gm.name + '_' + gm.footprint)) const presetEntry = presetStats.find((s) => s.field == `${gm.name}_${gm.footprint}`)
if (presetEntry) { if (presetEntry) {
return { return {
field: gm.name + '_' + gm.footprint, field: presetEntry.field,
text: gm.name + ' (' + gm.footprint + ')', text: `${gm.name} (${gm.footprint})`,
metric: gm.name, metric: gm.name,
from: presetEntry.from, from: presetEntry.from,
to: presetEntry.to, to: (presetEntry.to == 0) ? mc.peak : presetEntry.to,
peak: mc.peak, peak: mc.peak,
enabled: true enabled: true,
unit: `${gm?.unit?.prefix ? gm.unit.prefix : ''}${gm.unit.base}`
} }
} else { } else {
return { return {
field: gm.name + '_' + gm.footprint, field: `${gm.name}_${gm.footprint}`,
text: gm.name + ' (' + gm.footprint + ')', text: `${gm.name} (${gm.footprint})`,
metric: gm.name, metric: gm.name,
from: 0, from: 1,
to: mc.peak, to: mc.peak,
peak: mc.peak, peak: mc.peak,
enabled: false enabled: false,
unit: `${gm?.unit?.prefix ? gm.unit.prefix : ''}${gm.unit.base}`
} }
} }
} }