mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
42fc8048fe
* fix(metrics): host memory usage excludes reclaimable page cache Host RAM was computed as mem.used / mem.total via systeminformation, but mem.used counts reclaimable buffers/cache as used, so a busy Linux host read ~99%. Switch the dashboard stats, the fleet node self-report, and the host-RAM alert threshold to mem.active / mem.total (cache-excluded), and report used: mem.active and free: mem.available so the byte readout stays consistent with the percentage. Add regression tests for a cache-heavy host (no false alert) and a genuinely busy host (alert still fires). * test(metrics): assert system stats memory excludes reclaimable cache The cached /api/system/stats test mocked si.mem() without active/available, so after the route switched to the cache-excluded fields it produced a NaN percentage that the shape-only assertions did not catch. Fill the mock with a realistic shape and assert the route reports the active working set, not the cache-inclusive used/free.
246 lines
9.7 KiB
TypeScript
246 lines
9.7 KiB
TypeScript
/**
|
|
* Integration tests for cached HTTP endpoints:
|
|
* - /api/stats (2s TTL, invalidated on writes)
|
|
* - /api/system/stats (3s TTL, no write invalidation)
|
|
* - /api/stacks/statuses (3s TTL, invalidated on writes)
|
|
* - /api/system/cache-stats (admin observability)
|
|
*
|
|
* Verifies cache hit behavior (second call does not re-invoke the
|
|
* underlying Docker / si / FileSystem work), write-path invalidation
|
|
* (POST /api/stacks resets the cache), and that the admin endpoint
|
|
* reports per-namespace counters.
|
|
*
|
|
* The tests mock DockerController / FileSystemService at the service
|
|
* layer rather than hitting the real Docker socket, so they run in CI
|
|
* without requiring any external daemon.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
|
|
import request from 'supertest';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD } from './helpers/setupTestDb';
|
|
|
|
// ── Hoisted mocks (must come before importing the app) ─────────────────
|
|
|
|
const {
|
|
mockGetAllContainers,
|
|
mockGetBulkStackStatuses,
|
|
mockGetStacks,
|
|
mockCurrentLoad,
|
|
mockMem,
|
|
mockFsSize,
|
|
} = vi.hoisted(() => ({
|
|
mockGetAllContainers: vi.fn(),
|
|
mockGetBulkStackStatuses: vi.fn(),
|
|
mockGetStacks: vi.fn(),
|
|
mockCurrentLoad: vi.fn(),
|
|
mockMem: vi.fn(),
|
|
mockFsSize: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../services/DockerController', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/DockerController')>('../services/DockerController');
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual.default,
|
|
getInstance: () => ({
|
|
getAllContainers: mockGetAllContainers,
|
|
getBulkStackStatuses: mockGetBulkStackStatuses,
|
|
}),
|
|
},
|
|
globalDockerNetwork: { rxSec: 0, txSec: 0 },
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/FileSystemService', () => ({
|
|
FileSystemService: {
|
|
getInstance: () => ({
|
|
getStacks: mockGetStacks,
|
|
createStack: vi.fn().mockResolvedValue(undefined),
|
|
getBaseDir: () => '/tmp/compose',
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('systeminformation', () => ({
|
|
default: {
|
|
currentLoad: (...args: unknown[]) => mockCurrentLoad(...args),
|
|
mem: (...args: unknown[]) => mockMem(...args),
|
|
fsSize: (...args: unknown[]) => mockFsSize(...args),
|
|
},
|
|
currentLoad: (...args: unknown[]) => mockCurrentLoad(...args),
|
|
mem: (...args: unknown[]) => mockMem(...args),
|
|
fsSize: (...args: unknown[]) => mockFsSize(...args),
|
|
}));
|
|
|
|
// ── Setup ──────────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authCookie: string;
|
|
let CacheService: typeof import('../services/CacheService').CacheService;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
({ CacheService } = await import('../services/CacheService'));
|
|
|
|
const login = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
|
authCookie = (login.headers['set-cookie'] as unknown as string[])[0];
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
CacheService.getInstance().flush();
|
|
|
|
mockGetAllContainers.mockReset();
|
|
mockGetBulkStackStatuses.mockReset();
|
|
mockGetStacks.mockReset();
|
|
mockCurrentLoad.mockReset();
|
|
mockMem.mockReset();
|
|
mockFsSize.mockReset();
|
|
|
|
mockGetAllContainers.mockResolvedValue([
|
|
{ State: 'running', Labels: { 'com.docker.compose.project.working_dir': '/tmp/compose/a' } },
|
|
{ State: 'exited', Labels: {} },
|
|
]);
|
|
mockGetBulkStackStatuses.mockResolvedValue({});
|
|
mockGetStacks.mockResolvedValue([]);
|
|
mockCurrentLoad.mockResolvedValue({ currentLoad: 42.5, cpus: [{}, {}] });
|
|
// active/available exclude reclaimable cache; used/free include it. The route
|
|
// reports the cache-excluded figures, so the mock supplies the full shape:
|
|
// active(400) + available(600) = total; used(500) = active + 100 cache.
|
|
mockMem.mockResolvedValue({ total: 1000, used: 500, active: 400, free: 500, available: 600, buffcache: 100 });
|
|
mockFsSize.mockResolvedValue([{ fs: '/dev/sda1', mount: '/', size: 1000, used: 500, available: 500, use: 50 }]);
|
|
});
|
|
|
|
// ── /api/stats ─────────────────────────────────────────────────────────
|
|
|
|
describe('GET /api/stats caching', () => {
|
|
it('returns shape { active, managed, unmanaged, exited, total }', async () => {
|
|
const res = await request(app).get('/api/stats').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveProperty('active');
|
|
expect(res.body).toHaveProperty('managed');
|
|
expect(res.body).toHaveProperty('unmanaged');
|
|
expect(res.body).toHaveProperty('exited');
|
|
expect(res.body).toHaveProperty('total');
|
|
});
|
|
|
|
it('serves the second call from cache without re-invoking Docker', async () => {
|
|
await request(app).get('/api/stats').set('Cookie', authCookie);
|
|
await request(app).get('/api/stats').set('Cookie', authCookie);
|
|
expect(mockGetAllContainers).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('invalidates on POST /api/stacks', async () => {
|
|
await request(app).get('/api/stats').set('Cookie', authCookie);
|
|
expect(mockGetAllContainers).toHaveBeenCalledTimes(1);
|
|
|
|
const create = await request(app)
|
|
.post('/api/stacks')
|
|
.set('Cookie', authCookie)
|
|
.send({ stackName: 'new-stack' });
|
|
expect(create.status).toBe(200);
|
|
|
|
await request(app).get('/api/stats').set('Cookie', authCookie);
|
|
expect(mockGetAllContainers).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
// ── /api/system/stats ──────────────────────────────────────────────────
|
|
|
|
describe('GET /api/system/stats caching', () => {
|
|
it('collapses concurrent calls so si.currentLoad() runs once', async () => {
|
|
// Two back-to-back requests, the second should hit the cache.
|
|
await request(app).get('/api/system/stats').set('Cookie', authCookie);
|
|
await request(app).get('/api/system/stats').set('Cookie', authCookie);
|
|
expect(mockCurrentLoad).toHaveBeenCalledTimes(1);
|
|
expect(mockMem).toHaveBeenCalledTimes(1);
|
|
expect(mockFsSize).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('response includes network block that is read per-request outside the cache', async () => {
|
|
const res1 = await request(app).get('/api/system/stats').set('Cookie', authCookie);
|
|
const res2 = await request(app).get('/api/system/stats').set('Cookie', authCookie);
|
|
expect(res1.body).toHaveProperty('network');
|
|
expect(res2.body).toHaveProperty('network');
|
|
// CPU/mem/disk sample is cached; network is fresh per request.
|
|
expect(mockCurrentLoad).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reports memory from the active working set, excluding reclaimable cache', async () => {
|
|
const res = await request(app).get('/api/system/stats').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
// Figures come from mem.active / mem.available (cache-excluded), not the
|
|
// cache-inclusive mem.used / mem.free, so a busy host does not read ~100%.
|
|
expect(res.body.memory).toMatchObject({
|
|
total: 1000,
|
|
used: 400, // mem.active, not mem.used (500)
|
|
free: 600, // mem.available, not mem.free (500)
|
|
usagePercent: '40.0', // 400 / 1000, not 500 / 1000
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── /api/stacks/statuses ───────────────────────────────────────────────
|
|
|
|
describe('GET /api/stacks/statuses caching', () => {
|
|
it('serves repeat calls from cache without re-invoking the filesystem', async () => {
|
|
mockGetStacks.mockResolvedValue(['web', 'db']);
|
|
mockGetBulkStackStatuses.mockResolvedValue({
|
|
web: { status: 'running' },
|
|
db: { status: 'running' },
|
|
});
|
|
|
|
await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
|
|
await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
|
|
|
|
expect(mockGetStacks).toHaveBeenCalledTimes(1);
|
|
expect(mockGetBulkStackStatuses).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('invalidates on POST /api/stacks', async () => {
|
|
mockGetStacks.mockResolvedValue(['web']);
|
|
mockGetBulkStackStatuses.mockResolvedValue({ web: { status: 'running' } });
|
|
|
|
await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
|
|
expect(mockGetStacks).toHaveBeenCalledTimes(1);
|
|
|
|
await request(app)
|
|
.post('/api/stacks')
|
|
.set('Cookie', authCookie)
|
|
.send({ stackName: 'fresh-stack' });
|
|
|
|
await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
|
|
expect(mockGetStacks).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
// ── /api/system/cache-stats ────────────────────────────────────────────
|
|
|
|
describe('GET /api/system/cache-stats', () => {
|
|
it('requires admin auth', async () => {
|
|
const res = await request(app).get('/api/system/cache-stats');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns per-namespace hit/miss/stale counters', async () => {
|
|
// Generate some cache traffic first.
|
|
await request(app).get('/api/stats').set('Cookie', authCookie); // miss
|
|
await request(app).get('/api/stats').set('Cookie', authCookie); // hit
|
|
await request(app).get('/api/system/stats').set('Cookie', authCookie); // miss
|
|
|
|
const res = await request(app).get('/api/system/cache-stats').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.stats).toBeDefined();
|
|
expect(res.body.stats.hits).toBeGreaterThanOrEqual(1);
|
|
expect(res.body.stats.misses).toBeGreaterThanOrEqual(1);
|
|
expect(res.body['system-stats']).toBeDefined();
|
|
});
|
|
});
|