mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Explain container CPU scales and passive comparisons
Document host-capacity normalisation, version-qualified rounding and the difference between collected readings and delivered alerts. Keep comparisons on ordinary workload observations rather than forced load, threshold changes or diagnostic reruns. Contract-Neutral: Existing container CPU semantics and safety guidance only; no runtime, API, threshold, permission or lifecycle contract changes. Change-source: pulse-maintainer
This commit is contained in:
@@ -123,6 +123,57 @@ services:
|
||||
|
||||
---
|
||||
|
||||
## Container CPU readings
|
||||
|
||||
Pulse shows container CPU as a share of the **Docker/Podman host's total CPU
|
||||
capacity**: 100% means all its logical CPUs. `docker stats` and `podman stats`
|
||||
normally use **100% for one logical CPU**, so their readings can exceed 100%.
|
||||
For that scale, divide the stats percentage by the CPU count reported for the
|
||||
runtime host to compare it with Pulse.
|
||||
|
||||
If the engine runs inside a VM, use that VM's logical CPUs, not the physical
|
||||
Proxmox host's CPUs. Pulse's percentage is not utilisation of the container's
|
||||
own CPU quota or cpuset. If the reported CPU count differs from your configured
|
||||
VM size, retain both in the comparison rather than guessing a divisor.
|
||||
|
||||
| Example host logical CPUs | Stats CPU | Pulse CPU before display rounding |
|
||||
| --- | --- | --- |
|
||||
| 4 | 240% | 60% |
|
||||
| 6 | 16.52% | About 2.75% |
|
||||
| 6 | 1.04% | About 0.17% |
|
||||
|
||||
These are examples, not an assumption about your host. A smaller Pulse value
|
||||
alone does not establish a collection fault. Whole-percent table labels,
|
||||
including those in **v6.5.0-rc.1**, can display a fractional reading below 0.5%
|
||||
as 0%. Use **CPU History** rather than the rounded label; display rounding does
|
||||
not change stored readings or the values used by CPU alert evaluation. A dash
|
||||
or missing History is unavailable data, not a measured zero.
|
||||
|
||||
For a useful comparison:
|
||||
|
||||
1. Match the same container on the same runtime host. Compare a similar time
|
||||
interval in CPU History with `docker stats` or `podman stats`; separate
|
||||
sampling intervals can legitimately differ.
|
||||
2. Apply the reported host CPU count only to a stats reading on the per-CPU
|
||||
scale. Do not divide Pulse History again, or compare with an average since
|
||||
container start as though it were a recent interval.
|
||||
3. Check that observation times and History points advance during ordinary
|
||||
monitoring. A connection marked Active does not prove fresh CPU samples.
|
||||
4. If the normalised readings still disagree, or History stops advancing,
|
||||
report the server and agent versions, reported CPU count, sample times and
|
||||
bounded readings. Do not restart workloads, create CPU load, lower alert
|
||||
thresholds or export diagnostics just to make this comparison.
|
||||
|
||||
CPU alerts use the same host-capacity scale, with the configured thresholds,
|
||||
duration and suppression rules. Non-zero History proves a collected reading,
|
||||
not that an alert should fire or that its notification was delivered. If a
|
||||
normal workload already demonstrates an alert problem, retain the reading,
|
||||
threshold, duration and observed result; no forced alert test is needed. See
|
||||
[safe performance measurements](TROUBLESHOOTING.md#excessive-cpu-writes-or-database-growth)
|
||||
when measuring the Pulse server itself rather than a monitored container.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updates
|
||||
|
||||
These steps update the Pulse server, not the containers it monitors. Record the
|
||||
|
||||
@@ -251,6 +251,20 @@ repair an older generated unit rather than adding a JSON-parsing wrapper.
|
||||
follow the [platform-specific guide](TEMPERATURE_MONITORING.md) for any needed
|
||||
setup change during a maintenance window.
|
||||
|
||||
#### Container CPU differs from Docker or Podman stats
|
||||
|
||||
Pulse uses the runtime host's total CPU capacity; `docker stats` and
|
||||
`podman stats` normally use 100% per logical CPU. Divide a per-CPU stats reading
|
||||
by the **reported runtime host CPU count**, not the physical Proxmox host's
|
||||
count when the engine runs inside a VM. Compare the same container and time
|
||||
interval in **CPU History**, not just a rounded table label. A dash or missing
|
||||
History is unavailable, not zero; an Active connection alone is not fresh CPU
|
||||
evidence.
|
||||
|
||||
See [container CPU readings](DOCKER.md#container-cpu-readings) for worked
|
||||
examples, rounding and alert limits, and a passive comparison. Do not restart
|
||||
workloads, create CPU load or lower alert thresholds to test this.
|
||||
|
||||
#### Docker hosts appearing/disappearing
|
||||
|
||||
Cloned hosts can share a **saved Pulse agent ID**, not just an OS machine ID.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// The real Docs renderer, router and shipped help. No runtime socket, workload,
|
||||
// credentials, alert test or diagnostics collection is exercised.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = path.resolve('frontend-modern');
|
||||
const artifacts = path.join(root, 'node_modules', 'container-cpu-help-browser');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
process.chdir(root);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root, configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(root, 'node_modules', '.vite-container-cpu-help'),
|
||||
server: { host: '127.0.0.1', port: 5265, strictPort: true },
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
});
|
||||
const results = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [engine, width, tone] of [
|
||||
['chromium', 1280, 'light'], ['chromium', 1280, 'dark'],
|
||||
['webkit', 390, 'light'], ['webkit', 390, 'dark'],
|
||||
]) {
|
||||
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true });
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 },
|
||||
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
|
||||
const errors = [];
|
||||
const operations = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/') || request.method() !== 'GET')
|
||||
operations.push({ method: request.method(), url: request.url() });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5265/browser-tests/docs-fragment-navigation.html?scenario=troubleshooting',
|
||||
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
if (tone === 'dark') await page.evaluate(async () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const entry = page.getByRole('heading', {
|
||||
name: 'Container CPU differs from Docker or Podman stats', exact: true,
|
||||
});
|
||||
await entry.waitFor();
|
||||
await entry.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-${tone}-entry.png`) });
|
||||
const guideLink = page.getByRole('link', { name: 'container CPU readings', exact: true });
|
||||
if (engine === 'webkit') await guideLink.tap();
|
||||
else {
|
||||
await guideLink.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
const heading = page.getByRole('heading', { name: 'Container CPU readings', exact: true });
|
||||
await heading.waitFor();
|
||||
await page.waitForFunction(() => {
|
||||
const h = document.getElementById('container-cpu-readings');
|
||||
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
|
||||
});
|
||||
assert.ok(page.url().endsWith('/docs/DOCKER#container-cpu-readings'));
|
||||
const article = (await page.locator('article').innerText()).replace(/\s+/g, ' ');
|
||||
for (const phrase of ["Docker/Podman host's total CPU capacity", '100% for one logical CPU',
|
||||
'not an assumption about your host', 'v6.5.0-rc.1', 'unavailable data, not a measured zero',
|
||||
'Do not divide Pulse History again', 'Active does not prove fresh CPU samples',
|
||||
'not that an alert should fire or that its notification was delivered',
|
||||
'Do not restart workloads, create CPU load, lower alert thresholds'])
|
||||
assert.ok(article.includes(phrase), phrase);
|
||||
const table = page.locator('article table').filter({ hasText: 'Example host logical CPUs' });
|
||||
assert.equal(await table.getByRole('row').count(), 4);
|
||||
assert.equal(await table.getByRole('columnheader').count(), 3);
|
||||
const rows = await table.getByRole('row').allTextContents();
|
||||
assert.ok(rows[1].includes('240%') && rows[1].includes('60%'));
|
||||
assert.ok(rows[2].includes('16.52%') && rows[2].includes('2.75%'));
|
||||
assert.ok(rows[3].includes('1.04%') && rows[3].includes('0.17%'));
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await table.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-${tone}-examples.png`) });
|
||||
const performance = page.getByRole('link', { name: 'safe performance measurements', exact: true });
|
||||
if (engine === 'webkit') await performance.tap();
|
||||
else {
|
||||
await performance.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.getByRole('heading', { name: 'Excessive CPU, writes or database growth', exact: true }).waitFor();
|
||||
await page.waitForFunction(() => {
|
||||
const h = document.getElementById('excessive-cpu-writes-or-database-growth');
|
||||
return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
|
||||
});
|
||||
assert.ok(page.url().endsWith('/docs/TROUBLESHOOTING#excessive-cpu-writes-or-database-growth'));
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(operations, []);
|
||||
results.push({ engine, version: browser.version(), width, tone, exampleRows: rows,
|
||||
entryLink: engine === 'webkit' ? 'touch' : 'keyboard', performanceLink: true,
|
||||
noDocumentOverflow: true, errors, operations });
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
const docs = ['DOCKER.md', 'TROUBLESHOOTING.md'].map((name) => ({ name,
|
||||
sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'public', 'docs', name))).digest('hex'),
|
||||
}));
|
||||
const result = { result: 'passed', playwright: require('playwright/package.json').version,
|
||||
scope: 'shipped CPU help rendering and navigation, not collector accuracy or alert delivery', docs, results };
|
||||
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -123,6 +123,57 @@ services:
|
||||
|
||||
---
|
||||
|
||||
## Container CPU readings
|
||||
|
||||
Pulse shows container CPU as a share of the **Docker/Podman host's total CPU
|
||||
capacity**: 100% means all its logical CPUs. `docker stats` and `podman stats`
|
||||
normally use **100% for one logical CPU**, so their readings can exceed 100%.
|
||||
For that scale, divide the stats percentage by the CPU count reported for the
|
||||
runtime host to compare it with Pulse.
|
||||
|
||||
If the engine runs inside a VM, use that VM's logical CPUs, not the physical
|
||||
Proxmox host's CPUs. Pulse's percentage is not utilisation of the container's
|
||||
own CPU quota or cpuset. If the reported CPU count differs from your configured
|
||||
VM size, retain both in the comparison rather than guessing a divisor.
|
||||
|
||||
| Example host logical CPUs | Stats CPU | Pulse CPU before display rounding |
|
||||
| --- | --- | --- |
|
||||
| 4 | 240% | 60% |
|
||||
| 6 | 16.52% | About 2.75% |
|
||||
| 6 | 1.04% | About 0.17% |
|
||||
|
||||
These are examples, not an assumption about your host. A smaller Pulse value
|
||||
alone does not establish a collection fault. Whole-percent table labels,
|
||||
including those in **v6.5.0-rc.1**, can display a fractional reading below 0.5%
|
||||
as 0%. Use **CPU History** rather than the rounded label; display rounding does
|
||||
not change stored readings or the values used by CPU alert evaluation. A dash
|
||||
or missing History is unavailable data, not a measured zero.
|
||||
|
||||
For a useful comparison:
|
||||
|
||||
1. Match the same container on the same runtime host. Compare a similar time
|
||||
interval in CPU History with `docker stats` or `podman stats`; separate
|
||||
sampling intervals can legitimately differ.
|
||||
2. Apply the reported host CPU count only to a stats reading on the per-CPU
|
||||
scale. Do not divide Pulse History again, or compare with an average since
|
||||
container start as though it were a recent interval.
|
||||
3. Check that observation times and History points advance during ordinary
|
||||
monitoring. A connection marked Active does not prove fresh CPU samples.
|
||||
4. If the normalised readings still disagree, or History stops advancing,
|
||||
report the server and agent versions, reported CPU count, sample times and
|
||||
bounded readings. Do not restart workloads, create CPU load, lower alert
|
||||
thresholds or export diagnostics just to make this comparison.
|
||||
|
||||
CPU alerts use the same host-capacity scale, with the configured thresholds,
|
||||
duration and suppression rules. Non-zero History proves a collected reading,
|
||||
not that an alert should fire or that its notification was delivered. If a
|
||||
normal workload already demonstrates an alert problem, retain the reading,
|
||||
threshold, duration and observed result; no forced alert test is needed. See
|
||||
[safe performance measurements](TROUBLESHOOTING.md#excessive-cpu-writes-or-database-growth)
|
||||
when measuring the Pulse server itself rather than a monitored container.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updates
|
||||
|
||||
These steps update the Pulse server, not the containers it monitors. Record the
|
||||
|
||||
@@ -251,6 +251,20 @@ repair an older generated unit rather than adding a JSON-parsing wrapper.
|
||||
follow the [platform-specific guide](TEMPERATURE_MONITORING.md) for any needed
|
||||
setup change during a maintenance window.
|
||||
|
||||
#### Container CPU differs from Docker or Podman stats
|
||||
|
||||
Pulse uses the runtime host's total CPU capacity; `docker stats` and
|
||||
`podman stats` normally use 100% per logical CPU. Divide a per-CPU stats reading
|
||||
by the **reported runtime host CPU count**, not the physical Proxmox host's
|
||||
count when the engine runs inside a VM. Compare the same container and time
|
||||
interval in **CPU History**, not just a rounded table label. A dash or missing
|
||||
History is unavailable, not zero; an Active connection alone is not fresh CPU
|
||||
evidence.
|
||||
|
||||
See [container CPU readings](DOCKER.md#container-cpu-readings) for worked
|
||||
examples, rounding and alert limits, and a passive comparison. Do not restart
|
||||
workloads, create CPU load or lower alert thresholds to test this.
|
||||
|
||||
#### Docker hosts appearing/disappearing
|
||||
|
||||
Cloned hosts can share a **saved Pulse agent ID**, not just an OS machine ID.
|
||||
|
||||
@@ -480,6 +480,40 @@ describe('docsLinks', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('explains container CPU scales without inventing a zero or forcing an alert', () => {
|
||||
const docker = readFileSync(path.join(repoRoot, 'docs', 'DOCKER.md'), 'utf8');
|
||||
const cpu = docker
|
||||
.split('## Container CPU readings\n')[1]
|
||||
.split('\n---')[0]
|
||||
.replace(/\s+/g, ' ');
|
||||
expect(cpu).toContain("Docker/Podman host's total CPU capacity");
|
||||
expect(cpu).toContain('100% for one logical CPU');
|
||||
expect(cpu).toContain('CPU count reported for the runtime host');
|
||||
expect(cpu).toContain("not the physical Proxmox host's CPUs");
|
||||
expect(cpu).toContain("not utilisation of the container's own CPU quota or cpuset");
|
||||
expect(cpu).toContain('not an assumption about your host');
|
||||
expect(cpu).toContain('v6.5.0-rc.1');
|
||||
expect(cpu).toContain('not change stored readings or the values used by CPU alert evaluation');
|
||||
expect(cpu).toContain('unavailable data, not a measured zero');
|
||||
expect(cpu).toContain('Do not divide Pulse History again');
|
||||
expect(cpu).toContain('separate sampling intervals can legitimately differ');
|
||||
expect(cpu).toContain('Active does not prove fresh CPU samples');
|
||||
expect(cpu).toContain('not that an alert should fire or that its notification was delivered');
|
||||
expect(cpu).toContain('Do not restart workloads, create CPU load, lower alert thresholds');
|
||||
expect(cpu).toContain('no forced alert test is needed');
|
||||
|
||||
const troubleshooting = readFileSync(path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), 'utf8');
|
||||
const entry = troubleshooting
|
||||
.split('#### Container CPU differs from Docker or Podman stats\n')[1]
|
||||
.split('\n#### ')[0]
|
||||
.replace(/\s+/g, ' ');
|
||||
expect(entry).toContain('DOCKER.md#container-cpu-readings');
|
||||
expect(entry).toContain('reported runtime host CPU count');
|
||||
expect(entry).toContain('same container and time interval');
|
||||
expect(entry).toContain('unavailable, not zero');
|
||||
expect(entry).toContain('Do not restart workloads, create CPU load or lower alert thresholds');
|
||||
});
|
||||
|
||||
it('keeps clone identity recovery separate from destructive OS or credential resets', () => {
|
||||
const troubleshooting = readFileSync(path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), 'utf8');
|
||||
const agentGuide = readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8');
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package installtests
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
// Check the guide's actual worked examples against the normaliser used by
|
||||
// container ingestion, History and alert evaluation, not a second formula.
|
||||
func TestContainerCPUGuidanceExamplesMatchNormalisation(t *testing.T) {
|
||||
guide, err := os.ReadFile(repoFile("docs", "DOCKER.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := regexp.MustCompile(`(?m)^\| (\d+) \| ([\d.]+)% \| (?:About )?([\d.]+)% \|$`).FindAllStringSubmatch(string(guide), -1)
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected three worked host-capacity CPU examples, got %d", len(rows))
|
||||
}
|
||||
for _, row := range rows {
|
||||
cpus, err := strconv.Atoi(row[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := strconv.ParseFloat(row[2], 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
printed, err := strconv.ParseFloat(row[3], 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := models.NormalizeDockerContainerCPUCapacityPercent(raw, cpus)
|
||||
if math.Abs(actual-printed) > 0.005 {
|
||||
t.Errorf("%s disagrees with the production normaliser: %g", row[0], actual)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"DOCKER.md", "TROUBLESHOOTING.md"} {
|
||||
doc, err := os.ReadFile(repoFile("docs", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shipped, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", name))
|
||||
if err != nil || string(doc) != string(shipped) {
|
||||
t.Fatalf("%s shipped help does not match the checked canonical guide", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user