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)}`) + }) +}