From e6d39f6015884ac669318d42f625c71d650ea51b Mon Sep 17 00:00:00 2001
From: martino <32328813+f3rs3n@users.noreply.github.com>
Date: Tue, 27 Oct 2026 02:48:25 +0100
Subject: [PATCH] fix(monitor): keep overview Y-axis titles inside charts
---
.github/scripts/run_offline_qualified.py | 1 +
AppImage/components/network-traffic-chart.tsx | 2 +-
AppImage/components/node-metrics-charts.tsx | 4 +-
tests/test_overview_chart_axis_gutters.cjs | 118 ++++++++++++++++++
4 files changed, 122 insertions(+), 3 deletions(-)
create mode 100644 tests/test_overview_chart_axis_gutters.cjs
diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py
index 5880c6ba..d23535c0 100644
--- a/.github/scripts/run_offline_qualified.py
+++ b/.github/scripts/run_offline_qualified.py
@@ -58,6 +58,7 @@ NODE_FILES = (
"tests/test_borg_ssh_guidance.cjs",
"tests/test_storage_messages.cjs",
"tests/test_health_text.cjs",
+ "tests/test_overview_chart_axis_gutters.cjs",
)
# unittest's CLI accepts a missing pattern as a successful zero-test run.
diff --git a/AppImage/components/network-traffic-chart.tsx b/AppImage/components/network-traffic-chart.tsx
index 9fb637d9..e833311a 100644
--- a/AppImage/components/network-traffic-chart.tsx
+++ b/AppImage/components/network-traffic-chart.tsx
@@ -291,7 +291,7 @@ export function NetworkTrafficChart({
<>
-
+
-
+
-
+
prop.name.text === name)
+ assert.ok(matches.length <= 1, `duplicate ${name} prop`)
+ const value = matches[0]?.initializer
+ return value && ts.isJsxExpression(value) ? value.expression : value
+}
+
+function property(object, name) {
+ assert.ok(object && ts.isObjectLiteralExpression(object), 'explicit object required')
+ assert.ok(object.properties.every(ts.isPropertyAssignment), 'explicit object properties required')
+ const matches = object.properties.filter(prop => prop.name.text === name)
+ assert.ok(matches.length <= 1, `duplicate ${name} property`)
+ return matches[0]?.initializer
+}
+
+// Compare expression meaning at this small seam, ignoring whitespace/comments.
+// Unsupported syntax fails closed instead of silently accepting a missing label.
+function expression(node) {
+ assert.ok(node, 'label expression must remain present')
+ if (ts.isParenthesizedExpression(node)) return expression(node.expression)
+ if (ts.isStringLiteral(node)) return node.text
+ if (ts.isNumericLiteral(node)) return Number(node.text)
+ if (ts.isIdentifier(node)) return { identifier: node.text }
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) {
+ return -expression(node.operand)
+ }
+ if (ts.isCallExpression(node)) return { call: expression(node.expression), args: node.arguments.map(expression) }
+ if (ts.isBinaryExpression(node)) {
+ return { operator: ts.tokenToString(node.operatorToken.kind), left: expression(node.left), right: expression(node.right) }
+ }
+ if (ts.isConditionalExpression(node)) {
+ return { condition: expression(node.condition), yes: expression(node.whenTrue), no: expression(node.whenFalse) }
+ }
+ if (ts.isObjectLiteralExpression(node)) {
+ assert.ok(node.properties.every(ts.isPropertyAssignment), 'explicit label properties required')
+ const names = node.properties.map(prop => prop.name.text)
+ assert.equal(new Set(names).size, names.length, 'unique label properties required')
+ return Object.fromEntries(node.properties.map(prop => [prop.name.text, expression(prop.initializer)]))
+ }
+ assert.fail(`unsupported contract expression: ${ts.SyntaxKind[node.kind]}`)
+}
+
+function charts(file, component, expectedCount) {
+ const source = ts.createSourceFile(file, fs.readFileSync(path.join(app, 'components', file), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
+ assert.equal(source.parseDiagnostics.length, 0, `${file} must parse`)
+ const imports = source.statements.filter(node => ts.isImportDeclaration(node) && node.moduleSpecifier.text === 'recharts')
+ const bindings = imports.flatMap(node => node.importClause?.namedBindings?.elements || [])
+ const localName = name => {
+ const binding = bindings.find(node => (node.propertyName || node.name).text === name)
+ assert.ok(binding, `${name} must come from recharts`)
+ return binding.name.text
+ }
+ const [areaChartName, areaName, yAxisName] = ['AreaChart', 'Area', 'YAxis'].map(localName)
+ const functions = source.statements.filter(node => ts.isFunctionDeclaration(node) && node.name?.text === component)
+ assert.equal(functions.length, 1, `${component} must remain present`)
+ assert.ok(functions[0].modifiers?.some(node => node.kind === ts.SyntaxKind.ExportKeyword), `${component} must remain exported`)
+ const result = descendants(functions[0].body, node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === areaChartName)
+ assert.equal(result.length, expectedCount, `${component}: check every shipped AreaChart`)
+ return result.map(chart => ({
+ margin: attribute(chart.openingElement, 'margin'),
+ keys: chart.children.filter(ts.isJsxSelfClosingElement).filter(node => node.tagName.getText(source) === areaName).map(node => expression(attribute(node, 'dataKey'))),
+ axes: chart.children.filter(ts.isJsxSelfClosingElement).filter(node => node.tagName.getText(source) === yAxisName),
+ }))
+}
+
+const nodeCharts = charts('node-metrics-charts.tsx', 'NodeMetricsCharts', 2)
+const networkCharts = charts('network-traffic-chart.tsx', 'NetworkTrafficChart', 1)
+function chartFor(charts, key, axisCount) {
+ const matches = charts.filter(chart => chart.keys.includes(key))
+ assert.equal(matches.length, 1, `one actual ${key} chart required`)
+ assert.equal(matches[0].axes.length, axisCount, `${key}: labeled axes must remain`)
+ return matches[0]
+}
+const cpu = chartFor(nodeCharts, 'cpu', 2)
+const memory = chartFor(nodeCharts, 'memoryTotal', 1)
+const network = chartFor(networkCharts, 'netIn', 1)
+const identifier = name => ({ identifier: name })
+const desktopLabel = (value, side) => ({ value, angle: side === 'left' ? -90 : 90, position: side === 'left' ? 'insideLeft' : 'insideRight', fill: 'currentColor' })
+const mobileRule = label => ({ condition: identifier('isMobile'), yes: identifier('undefined'), no: label })
+const contracts = [
+ ['CPU %', cpu, 'left', mobileRule(desktopLabel('CPU %', 'left'))],
+ ['CPU Load', cpu, 'right', mobileRule(desktopLabel({ call: identifier('t'), args: ['overview.loadAxis'] }, 'right'))],
+ ['Memory GB', memory, 'left', mobileRule(desktopLabel('GB', 'left'))],
+ ['Network GB/Gb', network, 'left', desktopLabel({ condition: { operator: '===', left: identifier('networkUnit'), right: 'Bits' }, yes: 'Gb', no: 'GB' }, 'left')],
+]
+for (const [name, chart, side, label] of contracts) {
+ test(`${name}: label retained with >=8px internal ${side} gutter (source contract)`, () => {
+ const axes = chart.axes.filter(axis => {
+ const orientation = attribute(axis, 'orientation')
+ return (orientation ? expression(orientation) : 'left') === side
+ })
+ assert.equal(axes.length, 1, `${name}: one ${side} YAxis required`)
+ assert.deepEqual(expression(attribute(axes[0], 'label')), label, `${name}: preserve label, unit and mobile rule`)
+ const gutterNode = property(chart.margin, side)
+ const gutter = gutterNode ? expression(gutterNode) : 0
+ assert.ok(typeof gutter === 'number' && gutter >= 8, `${name}: ${side} AreaChart margin must be >=8px; got ${JSON.stringify(gutter)}`)
+ })
+}