diff --git a/CHANGELOG.md b/CHANGELOG.md index 2554b2b6..f91935b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **perf:** unify caching across the backend behind a single internal service with + per-namespace hit/miss/stale observability. Hot-path dashboard endpoints + (`/api/stats`, `/api/system/stats`, `/api/stacks/statuses`) now cache expensive + Docker and system sampling calls for 2 to 3 seconds with write-path invalidation, + so user actions inside Sencho stay instantly reflected while heavy polling no + longer hammers the Docker socket or the `systeminformation` CPU sampler. A new + admin-only `GET /api/system/cache-stats` endpoint exposes per-namespace counters + for operators who want to observe cache effectiveness. +* **perf:** enable HTTP response compression (gzip) for JSON responses site-wide. + Large payloads like `/api/templates` shrink roughly 5x on the wire, with + Server-Sent Event streams explicitly excluded so real-time log tails and system + metrics are not buffered. + ### Fixed * **api:** add tiered rate limiting to prevent dashboard polling lockouts. High-frequency diff --git a/backend/package-lock.json b/backend/package-lock.json index 04257c18..399e0ac8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-ecr": "^3.1019.0", + "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/dockerode": "^4.0.1", "@types/express": "^5.0.6", @@ -21,6 +22,7 @@ "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", "composerize": "^1.7.5", + "compression": "^1.8.1", "cookie-parser": "^1.4.7", "cors": "^2.8.6", "cron-parser": "^5.5.0", @@ -1997,6 +1999,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3148,6 +3160,60 @@ "yaml": "^2.x" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -5353,6 +5419,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/backend/package.json b/backend/package.json index 130f0fcd..c8e54f06 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@aws-sdk/client-ecr": "^3.1019.0", + "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/dockerode": "^4.0.1", "@types/express": "^5.0.6", @@ -47,6 +48,7 @@ "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", "composerize": "^1.7.5", + "compression": "^1.8.1", "cookie-parser": "^1.4.7", "cors": "^2.8.6", "cron-parser": "^5.5.0", diff --git a/backend/src/__tests__/cache-endpoints.test.ts b/backend/src/__tests__/cache-endpoints.test.ts new file mode 100644 index 00000000..d498135a --- /dev/null +++ b/backend/src/__tests__/cache-endpoints.test.ts @@ -0,0 +1,229 @@ +/** + * 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('../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: [{}, {}] }); + mockMem.mockResolvedValue({ total: 1000, used: 500, free: 500 }); + 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); + }); +}); + +// ── /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(); + }); +}); diff --git a/backend/src/__tests__/cache-service.test.ts b/backend/src/__tests__/cache-service.test.ts new file mode 100644 index 00000000..40e46c93 --- /dev/null +++ b/backend/src/__tests__/cache-service.test.ts @@ -0,0 +1,321 @@ +/** + * Unit tests for CacheService: TTL expiry, inflight deduplication, + * stale-on-error fallback, namespace invalidation, per-namespace stats, + * the entry-cap safety guard, and singleton identity. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CacheService } from '../services/CacheService'; + +describe('CacheService', () => { + let cache: CacheService; + + beforeEach(() => { + cache = CacheService.getInstance(); + cache.flush(); + }); + + afterEach(() => { + cache.flush(); + vi.useRealTimers(); + }); + + // ─── singleton ──────────────────────────────────────────────────────── + + describe('getInstance', () => { + it('returns the same instance across calls', () => { + const a = CacheService.getInstance(); + const b = CacheService.getInstance(); + expect(a).toBe(b); + }); + }); + + // ─── getOrFetch: cache hits and misses ──────────────────────────────── + + describe('getOrFetch', () => { + it('calls fetcher on first access and caches the result', async () => { + const fetcher = vi.fn().mockResolvedValue('fresh-value'); + const result = await cache.getOrFetch('ns:key', 60_000, fetcher); + expect(result).toBe('fresh-value'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('returns cached value on subsequent access without calling fetcher', async () => { + const fetcher = vi.fn().mockResolvedValue('cached'); + await cache.getOrFetch('ns:key', 60_000, fetcher); + const second = await cache.getOrFetch('ns:key', 60_000, fetcher); + expect(second).toBe('cached'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('refetches after TTL expiry', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const fetcher = vi.fn() + .mockResolvedValueOnce('v1') + .mockResolvedValueOnce('v2'); + + const first = await cache.getOrFetch('ns:key', 1_000, fetcher); + expect(first).toBe('v1'); + + await vi.advanceTimersByTimeAsync(1_100); + + const second = await cache.getOrFetch('ns:key', 1_000, fetcher); + expect(second).toBe('v2'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('supports different value types', async () => { + const obj = { a: 1, b: [2, 3] }; + await cache.getOrFetch('obj:key', 60_000, async () => obj); + const out = await cache.getOrFetch('obj:key', 60_000, async () => ({ a: 99, b: [] })); + expect(out).toEqual(obj); + }); + }); + + // ─── inflight deduplication ────────────────────────────────────────── + + describe('inflight deduplication', () => { + it('deduplicates concurrent getOrFetch calls for the same key', async () => { + let resolveFetch!: (value: string) => void; + const fetcher = vi.fn(() => new Promise((resolve) => { + resolveFetch = resolve; + })); + + const p1 = cache.getOrFetch('ns:key', 60_000, fetcher); + const p2 = cache.getOrFetch('ns:key', 60_000, fetcher); + const p3 = cache.getOrFetch('ns:key', 60_000, fetcher); + + resolveFetch('shared'); + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + expect(r1).toBe('shared'); + expect(r2).toBe('shared'); + expect(r3).toBe('shared'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('does not deduplicate across different keys', async () => { + const fetcher = vi.fn(async (v: string) => v); + await Promise.all([ + cache.getOrFetch('ns:a', 60_000, () => fetcher('a')), + cache.getOrFetch('ns:b', 60_000, () => fetcher('b')), + ]); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('clears inflight after successful fetch so next miss can refetch', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const fetcher = vi.fn() + .mockResolvedValueOnce('v1') + .mockResolvedValueOnce('v2'); + + await cache.getOrFetch('ns:key', 500, fetcher); + await vi.advanceTimersByTimeAsync(600); + const again = await cache.getOrFetch('ns:key', 500, fetcher); + + expect(again).toBe('v2'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('clears inflight after rejection so a later call can retry', async () => { + const fetcher = vi.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce('recovered'); + + await expect(cache.getOrFetch('ns:key', 60_000, fetcher)).rejects.toThrow('boom'); + const second = await cache.getOrFetch('ns:key', 60_000, fetcher); + expect(second).toBe('recovered'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + }); + + // ─── stale-on-error fallback ───────────────────────────────────────── + + describe('stale-on-error fallback', () => { + it('returns stale value when fetcher rejects after the entry expires', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const fetcher = vi.fn() + .mockResolvedValueOnce('original') + .mockRejectedValueOnce(new Error('upstream down')); + + const fresh = await cache.getOrFetch('ns:key', 1_000, fetcher); + expect(fresh).toBe('original'); + + await vi.advanceTimersByTimeAsync(1_100); + + const stale = await cache.getOrFetch('ns:key', 1_000, fetcher); + expect(stale).toBe('original'); + expect(fetcher).toHaveBeenCalledTimes(2); + + const stats = cache.getStats(); + expect(stats.ns?.stale).toBe(1); + }); + + it('propagates error when no stale entry exists', async () => { + const fetcher = vi.fn().mockRejectedValue(new Error('no fallback')); + await expect(cache.getOrFetch('ns:key', 60_000, fetcher)).rejects.toThrow('no fallback'); + }); + }); + + // ─── synchronous get ───────────────────────────────────────────────── + + describe('get', () => { + it('returns undefined for missing keys and records a miss', () => { + expect(cache.get('ns:missing')).toBeUndefined(); + const stats = cache.getStats(); + expect(stats.ns?.misses).toBe(1); + }); + + it('returns the cached value and records a hit', () => { + cache.set('ns:key', 42, 60_000); + expect(cache.get('ns:key')).toBe(42); + const stats = cache.getStats(); + expect(stats.ns?.hits).toBe(1); + }); + + it('returns undefined and records a miss when the entry is expired', () => { + vi.useFakeTimers({ shouldAdvanceTime: false }); + cache.set('ns:key', 'v', 1_000); + vi.advanceTimersByTime(1_500); + expect(cache.get('ns:key')).toBeUndefined(); + const stats = cache.getStats(); + expect(stats.ns?.misses).toBe(1); + }); + }); + + // ─── invalidate ─────────────────────────────────────────────────────── + + describe('invalidate', () => { + it('removes a single key', async () => { + await cache.getOrFetch('ns:key', 60_000, async () => 'v'); + cache.invalidate('ns:key'); + const fetcher = vi.fn().mockResolvedValue('refetched'); + const out = await cache.getOrFetch('ns:key', 60_000, fetcher); + expect(out).toBe('refetched'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('is a no-op for missing keys', () => { + expect(() => cache.invalidate('ns:missing')).not.toThrow(); + }); + }); + + describe('invalidateNamespace', () => { + it('removes every key within the namespace', async () => { + await cache.getOrFetch('stats:1', 60_000, async () => 'a'); + await cache.getOrFetch('stats:2', 60_000, async () => 'b'); + await cache.getOrFetch('other:1', 60_000, async () => 'c'); + + cache.invalidateNamespace('stats'); + + const f1 = vi.fn().mockResolvedValue('refetched-1'); + const f2 = vi.fn().mockResolvedValue('refetched-2'); + const f3 = vi.fn().mockResolvedValue('kept-c'); + + expect(await cache.getOrFetch('stats:1', 60_000, f1)).toBe('refetched-1'); + expect(await cache.getOrFetch('stats:2', 60_000, f2)).toBe('refetched-2'); + expect(await cache.getOrFetch('other:1', 60_000, f3)).toBe('c'); + expect(f3).not.toHaveBeenCalled(); + }); + + it('does not remove keys with similar but distinct namespaces', async () => { + await cache.getOrFetch('stats:1', 60_000, async () => 'a'); + await cache.getOrFetch('statsbar:1', 60_000, async () => 'b'); + + cache.invalidateNamespace('stats'); + + const f = vi.fn().mockResolvedValue('new'); + expect(await cache.getOrFetch('statsbar:1', 60_000, f)).toBe('b'); + expect(f).not.toHaveBeenCalled(); + }); + + it('removes a namespace-only key (no colon suffix)', async () => { + await cache.getOrFetch('singleton', 60_000, async () => 'one'); + cache.invalidateNamespace('singleton'); + const f = vi.fn().mockResolvedValue('two'); + expect(await cache.getOrFetch('singleton', 60_000, f)).toBe('two'); + }); + }); + + // ─── stats ──────────────────────────────────────────────────────────── + + describe('getStats', () => { + it('counts hits and misses per namespace', async () => { + await cache.getOrFetch('stats:1', 60_000, async () => 'a'); // miss + await cache.getOrFetch('stats:1', 60_000, async () => 'x'); // hit + await cache.getOrFetch('stats:2', 60_000, async () => 'b'); // miss + await cache.getOrFetch('other:1', 60_000, async () => 'c'); // miss + + const stats = cache.getStats(); + expect(stats.stats).toEqual({ hits: 1, misses: 2, stale: 0, size: 2 }); + expect(stats.other).toEqual({ hits: 0, misses: 1, stale: 0, size: 1 }); + }); + + it('only counts live (non-expired) entries in size', async () => { + vi.useFakeTimers({ shouldAdvanceTime: false }); + cache.set('ns:a', 1, 1_000); + cache.set('ns:b', 2, 10_000); + vi.advanceTimersByTime(2_000); + const stats = cache.getStats(); + expect(stats.ns?.size).toBe(1); + }); + + it('returns an empty object after flush', () => { + cache.set('ns:a', 1, 60_000); + cache.flush(); + expect(cache.getStats()).toEqual({}); + }); + }); + + // ─── flush ──────────────────────────────────────────────────────────── + + describe('flush', () => { + it('clears store, inflight, and stats', async () => { + await cache.getOrFetch('ns:a', 60_000, async () => 1); + cache.flush(); + expect(cache.get('ns:a')).toBeUndefined(); + // Reset stats count: after flush the previous miss counter is gone, + // the single get() call above registers one new miss. + const stats = cache.getStats(); + expect(stats.ns?.misses).toBe(1); + expect(stats.ns?.hits).toBe(0); + }); + }); + + // ─── MAX_ENTRIES safety cap ────────────────────────────────────────── + + describe('entry cap safety guard', () => { + it('refuses new entries once the cap is reached and no expired rows exist', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Fill up to the cap with long-lived entries. + for (let i = 0; i < 1000; i++) { + cache.set(`bulk:${i}`, i, 3_600_000); + } + // Cap reached with all entries still live: insertion is rejected. + cache.set('bulk:overflow', 'x', 60_000); + expect(cache.get('bulk:overflow')).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('purges expired entries to make room for new ones', () => { + vi.useFakeTimers({ shouldAdvanceTime: false }); + // Fill with short-lived entries. + for (let i = 0; i < 1000; i++) { + cache.set(`bulk:${i}`, i, 500); + } + // Expire them all. + vi.advanceTimersByTime(1_000); + // A new insertion should trigger a purge and succeed. + cache.set('bulk:new', 'accepted', 60_000); + expect(cache.get('bulk:new')).toBe('accepted'); + }); + + it('allows overwriting an existing key when the cap is reached', () => { + for (let i = 0; i < 1000; i++) { + cache.set(`bulk:${i}`, i, 3_600_000); + } + cache.set('bulk:5', 'updated', 60_000); + expect(cache.get('bulk:5')).toBe('updated'); + }); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index ccc7310c..578dbe90 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,7 @@ import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; +import compression from 'compression'; import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; import helmet from 'helmet'; import WebSocket, { WebSocketServer } from 'ws'; @@ -30,7 +31,31 @@ import { WebhookService } from './services/WebhookService'; import { SSOService } from './services/SSOService'; import { SchedulerService } from './services/SchedulerService'; import { RegistryService } from './services/RegistryService'; +import { CacheService } from './services/CacheService'; import { CAPABILITIES, getSenchoVersion, isValidVersion, fetchRemoteMeta, getActiveCapabilities, type RemoteMeta } from './services/CapabilityRegistry'; + +// ── Hot-path cache TTLs ──────────────────────────────────────────────── +// Short TTLs collapse concurrent polling pressure across browser tabs and +// overlapping service samplers without introducing noticeable UI staleness. +// Keys are per-node: "stats:", "system-stats:", "stack-statuses:". +const STATS_CACHE_TTL_MS = 2_000; +const SYSTEM_STATS_CACHE_TTL_MS = 3_000; +const STACK_STATUSES_CACHE_TTL_MS = 3_000; + +/** + * Invalidate the per-node caches affected by a stack/container mutation so + * the next dashboard poll shows fresh state instead of stale reads. Called + * from every endpoint that changes the Docker or filesystem state. + * + * Also drops the global `project-name-map` since stack writes (create, delete, + * rename, compose edits) can reshape the on-disk layout used to build it. + */ +function invalidateNodeCaches(nodeId: number): void { + const cache = CacheService.getInstance(); + cache.invalidate(`stats:${nodeId}`); + cache.invalidate(`stack-statuses:${nodeId}`); + cache.invalidate('project-name-map'); +} import SelfUpdateService from './services/SelfUpdateService'; import semver from 'semver'; import { CronExpressionParser } from 'cron-parser'; @@ -138,6 +163,19 @@ app.use(cors({ credentials: true, })); +// Gzip JSON and HTML responses. SSE streams (Content-Type: text/event-stream) +// MUST NOT be compressed because compression buffers output and would delay +// event delivery until a flush, breaking live log and status streams. +app.use(compression({ + filter: (req: Request, res: Response) => { + const ct = res.getHeader('Content-Type'); + if (typeof ct === 'string' && ct.includes('text/event-stream')) { + return false; + } + return compression.filter(req, res); + }, +})); + // Cookie parser must run before rate limiters so the hybrid key generator // can read req.cookies for per-user rate limit bucketing. app.use(cookieParser()); @@ -1236,9 +1274,10 @@ const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const UPDATE_TIMEOUT_MSG = 'Node did not come back online within 5 minutes.'; const EARLY_FAIL_MS = 180 * 1000; // 3 minutes before declaring a probable pull failure -// Latest Sencho version cache (fetched from GitHub Releases) -let latestVersionCache: { version: string; fetchedAt: number } | null = null; -let latestVersionInflight: Promise | null = null; +// Latest Sencho version cache (fetched from GitHub Releases). +// Backed by CacheService: TTL, inflight dedup, and stale-on-error are all +// handled by the unified cache layer. +const LATEST_VERSION_CACHE_KEY = 'latest-version'; const LATEST_VERSION_CACHE_TTL = 30 * 60 * 1000; // 30 minutes async function fetchFromGitHub(): Promise { @@ -1267,7 +1306,7 @@ async function fetchFromDockerHub(): Promise { return tags[0]; } -async function fetchLatestSenchoVersion(): Promise { +async function fetchLatestSenchoVersion(): Promise { try { const gh = await fetchFromGitHub(); if (gh) return gh; @@ -1276,31 +1315,29 @@ async function fetchLatestSenchoVersion(): Promise { console.warn('[VersionCheck] GitHub fetch failed:', (err as Error).message); } try { - return await fetchFromDockerHub(); + const hub = await fetchFromDockerHub(); + if (hub) return hub; } catch (err) { console.warn('[VersionCheck] Docker Hub fetch failed:', (err as Error).message); - return null; } + // Throw so CacheService falls back to a stale value if one exists, + // and so we do not poison the cache with null. + throw new Error('Both GitHub and Docker Hub version lookups failed'); } async function getLatestVersion(forceRefresh = false): Promise { - if ( - !forceRefresh && - latestVersionCache && - Date.now() - latestVersionCache.fetchedAt < LATEST_VERSION_CACHE_TTL - ) { - return latestVersionCache.version; + if (forceRefresh) { + CacheService.getInstance().invalidate(LATEST_VERSION_CACHE_KEY); } - // Deduplicate concurrent requests (thundering herd protection) - if (!latestVersionInflight) { - latestVersionInflight = fetchLatestSenchoVersion().finally(() => { latestVersionInflight = null; }); + try { + return await CacheService.getInstance().getOrFetch( + LATEST_VERSION_CACHE_KEY, + LATEST_VERSION_CACHE_TTL, + fetchLatestSenchoVersion, + ); + } catch { + return null; } - const version = await latestVersionInflight; - if (version) { - latestVersionCache = { version, fetchedAt: Date.now() }; - } - // On failure, return stale cache if available (graceful degradation) - return version ?? latestVersionCache?.version ?? null; } /** Resolve the version to compare nodes against (latest from GitHub, or gateway fallback). */ @@ -3280,6 +3317,9 @@ app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Res } } + if (results.some(r => r.success)) { + invalidateNodeCaches(req.nodeId); + } res.json({ results }); } catch (error) { console.error('[Labels] Bulk action error:', error); @@ -3300,16 +3340,23 @@ app.get('/api/stacks', async (req: Request, res: Response) => { app.get('/api/stacks/statuses', async (req: Request, res: Response) => { try { - const stacks = await FileSystemService.getInstance(req.nodeId).getStacks(); - const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, '')); - const dockerController = DockerController.getInstance(req.nodeId); - const bulkInfo = await dockerController.getBulkStackStatuses(stackNames); - // Map back to filenames to match frontend expectations - const result: Record = {}; - for (const stack of stacks) { - const name = stack.replace(/\.(yml|yaml)$/, ''); - result[stack] = bulkInfo[name] ?? { status: 'unknown' }; - } + const result = await CacheService.getInstance().getOrFetch( + `stack-statuses:${req.nodeId}`, + STACK_STATUSES_CACHE_TTL_MS, + async () => { + const stacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, '')); + const dockerController = DockerController.getInstance(req.nodeId); + const bulkInfo = await dockerController.getBulkStackStatuses(stackNames); + // Map back to filenames to match frontend expectations + const data: Record = {}; + for (const stack of stacks) { + const name = stack.replace(/\.(yml|yaml)$/, ''); + data[stack] = bulkInfo[name] ?? { status: 'unknown' }; + } + return data; + }, + ); res.json(result); } catch (error) { console.error('Failed to fetch stack statuses:', error); @@ -3343,6 +3390,7 @@ app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { return res.status(400).json({ error: 'Content must be a string' }); } await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content); + invalidateNodeCaches(req.nodeId); res.json({ message: 'Stack saved successfully' }); } catch (error) { console.error('Failed to save stack:', error); @@ -3524,6 +3572,7 @@ app.post('/api/stacks', async (req: Request, res: Response) => { return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters and hyphens' }); } await FileSystemService.getInstance(req.nodeId).createStack(stackName); + invalidateNodeCaches(req.nodeId); res.json({ message: 'Stack created successfully', name: stackName }); } catch (error: any) { if (error.message && error.message.includes('already exists')) { @@ -3551,6 +3600,7 @@ app.delete('/api/stacks/:name', async (req: Request, res: Response) => { // Stage 2: Obliterate the files await FileSystemService.getInstance(req.nodeId).deleteStack(stackName); + invalidateNodeCaches(req.nodeId); res.json({ success: true }); } catch (error: any) { res.status(500).json({ error: error.message || 'Failed to delete stack' }); @@ -3604,6 +3654,7 @@ app.post('/api/containers/:id/start', async (req: Request, res: Response) => { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); await dockerController.startContainer(id); + invalidateNodeCaches(req.nodeId); res.json({ message: 'Container started' }); } catch (error) { res.status(500).json({ error: 'Failed to start container' }); @@ -3616,6 +3667,7 @@ app.post('/api/containers/:id/stop', async (req: Request, res: Response) => { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); await dockerController.stopContainer(id); + invalidateNodeCaches(req.nodeId); res.json({ message: 'Container stopped' }); } catch (error) { res.status(500).json({ error: 'Failed to stop container' }); @@ -3628,6 +3680,7 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => { const id = req.params.id as string; const dockerController = DockerController.getInstance(req.nodeId); await dockerController.restartContainer(id); + invalidateNodeCaches(req.nodeId); res.json({ message: 'Container restarted' }); } catch (error) { res.status(500).json({ error: 'Failed to restart container' }); @@ -3644,6 +3697,7 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => try { const atomic = LicenseService.getInstance().getTier() === 'paid'; await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic); + invalidateNodeCaches(req.nodeId); res.json({ message: 'Deployed successfully' }); } catch (error: any) { console.error('Failed to deploy stack:', error); @@ -3660,6 +3714,7 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { } try { await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined); + invalidateNodeCaches(req.nodeId); res.json({ status: 'Command started' }); } catch (error) { res.status(500).json({ error: 'Failed to start command' }); @@ -3681,6 +3736,7 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) = } await Promise.all(containers.map(c => dockerController.restartContainer(c.Id))); + invalidateNodeCaches(req.nodeId); res.json({ success: true, message: 'Restart completed via Engine API.' }); } catch (error: any) { console.error('Failed to restart containers:', error); @@ -3703,6 +3759,7 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { } await Promise.all(containers.map(c => dockerController.stopContainer(c.Id))); + invalidateNodeCaches(req.nodeId); res.json({ success: true, message: 'Stop completed via Engine API.' }); } catch (error: any) { console.error('Failed to stop containers:', error); @@ -3725,6 +3782,7 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => } await Promise.all(containers.map(c => dockerController.startContainer(c.Id))); + invalidateNodeCaches(req.nodeId); res.json({ success: true, message: 'Start completed via Engine API.' }); } catch (error: any) { console.error('Failed to start containers:', error); @@ -3743,6 +3801,7 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => const atomic = LicenseService.getInstance().getTier() === 'paid'; await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic); DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName); + invalidateNodeCaches(req.nodeId); res.json({ status: 'Update completed' }); } catch (error) { const rolledBack = LicenseService.getInstance().getTier() === 'paid'; @@ -3767,6 +3826,7 @@ app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) await fsSvc.restoreStackFiles(stackName); // Re-deploy with restored files (non-atomic to avoid loops) await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, false); + invalidateNodeCaches(req.nodeId); res.json({ message: 'Stack rolled back successfully.' }); } catch (error: any) { console.error('Rollback failed:', error); @@ -3805,30 +3865,39 @@ app.post('/api/convert', async (req: Request, res: Response) => { } }); -// Get all containers stats for dashboard +// Get all containers stats for dashboard. +// Cached per-node for 2s to collapse multi-tab polling pressure. Invalidated +// by stack/container write endpoints (deploy, down, start, stop, restart, etc). app.get('/api/stats', async (req: Request, res: Response) => { try { const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(req.nodeId)); - const allContainers = await DockerController.getInstance(req.nodeId).getAllContainers(); + const result = await CacheService.getInstance().getOrFetch( + `stats:${req.nodeId}`, + STATS_CACHE_TTL_MS, + async () => { + const allContainers = await DockerController.getInstance(req.nodeId).getAllContainers(); - // A container is "managed" if Docker started it from within COMPOSE_DIR. - // We use com.docker.compose.project.working_dir rather than project name because - // stacks launched from the COMPOSE_DIR root (not a subdirectory) all share the - // project name of the root folder - causing false "external" classification. - const isManagedByComposeDir = (c: any): boolean => { - const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir']; - if (!workingDir) return false; - const resolved = path.resolve(workingDir); - return resolved === composeDir || resolved.startsWith(composeDir + path.sep); - }; + // A container is "managed" if Docker started it from within COMPOSE_DIR. + // We use com.docker.compose.project.working_dir rather than project name because + // stacks launched from the COMPOSE_DIR root (not a subdirectory) all share the + // project name of the root folder, causing false "external" classification. + const isManagedByComposeDir = (c: any): boolean => { + const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir']; + if (!workingDir) return false; + const resolved = path.resolve(workingDir); + return resolved === composeDir || resolved.startsWith(composeDir + path.sep); + }; - const active = allContainers.filter((c: any) => c.State === 'running').length; - const exited = allContainers.filter((c: any) => c.State === 'exited').length; - const total = allContainers.length; - const managed = allContainers.filter((c: any) => c.State === 'running' && isManagedByComposeDir(c)).length; - const unmanaged = allContainers.filter((c: any) => c.State === 'running' && !isManagedByComposeDir(c)).length; + const active = allContainers.filter((c: any) => c.State === 'running').length; + const exited = allContainers.filter((c: any) => c.State === 'exited').length; + const total = allContainers.length; + const managed = allContainers.filter((c: any) => c.State === 'running' && isManagedByComposeDir(c)).length; + const unmanaged = allContainers.filter((c: any) => c.State === 'running' && !isManagedByComposeDir(c)).length; - res.json({ active, managed, unmanaged, exited, total }); + return { active, managed, unmanaged, exited, total }; + }, + ); + res.json(result); } catch (error) { res.status(500).json({ error: 'Failed to fetch stats' }); } @@ -4037,49 +4106,74 @@ app.get('/api/logs/global/stream', async (req: Request, res: Response) => { } }); -// Get host system stats +// Get host system stats. +// Cached for 3s to collapse overlapping samplers: the dashboard polls every 5s, +// MonitorService samples every 30s, and si.currentLoad() blocks for ~200ms per +// call. A short TTL makes concurrent polls share one sample without noticeable +// UX staleness. No write-path invalidation: these are pure host metrics. app.get('/api/system/stats', async (req: Request, res: Response) => { try { + // Network is read outside the cache because it is cheap and per-request. const rxSec = Math.max(0, globalDockerNetwork.rxSec); const txSec = Math.max(0, globalDockerNetwork.txSec); - // Remote node requests are intercepted and proxied by remoteNodeProxy before reaching here. - // This handler only runs for local nodes. - const [currentLoad, mem, fsSize] = await Promise.all([ - si.currentLoad(), - si.mem(), - si.fsSize() - ]); + const sample = await CacheService.getInstance().getOrFetch( + `system-stats:${req.nodeId}`, + SYSTEM_STATS_CACHE_TTL_MS, + async () => { + // Remote node requests are intercepted and proxied by remoteNodeProxy + // before reaching here. This fetcher only runs for local nodes. + const [currentLoad, mem, fsSize] = await Promise.all([ + si.currentLoad(), + si.mem(), + si.fsSize(), + ]); - const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; + const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0]; - res.json({ - cpu: { - usage: currentLoad.currentLoad.toFixed(1), - cores: currentLoad.cpus.length, + return { + cpu: { + usage: currentLoad.currentLoad.toFixed(1), + cores: currentLoad.cpus.length, + }, + memory: { + total: mem.total, + used: mem.used, + free: mem.free, + usagePercent: ((mem.used / mem.total) * 100).toFixed(1), + }, + disk: mainDisk ? { + fs: mainDisk.fs, + mount: mainDisk.mount, + total: mainDisk.size, + used: mainDisk.used, + free: mainDisk.available, + usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0', + } : null, + }; }, - memory: { - total: mem.total, - used: mem.used, - free: mem.free, - usagePercent: ((mem.used / mem.total) * 100).toFixed(1), - }, - disk: mainDisk ? { - fs: mainDisk.fs, - mount: mainDisk.mount, - total: mainDisk.size, - used: mainDisk.used, - free: mainDisk.available, - usagePercent: mainDisk.use ? mainDisk.use.toFixed(1) : '0', - } : null, - network: { rxBytes: 0, txBytes: 0, rxSec, txSec }, - }); + ); + + res.json({ ...sample, network: { rxBytes: 0, txBytes: 0, rxSec, txSec } }); } catch (error) { console.error('Failed to fetch system stats:', error); res.status(500).json({ error: 'Failed to fetch system stats' }); } }); +// Admin-only cache observability: per-namespace hit/miss/stale counters and +// live entry counts for the unified CacheService. Used by Settings → About and +// for post-deployment verification that cache hit rates look healthy. +app.get('/api/system/cache-stats', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + res.json(CacheService.getInstance().getStats()); + } catch (error) { + console.error('Failed to fetch cache stats:', error); + res.status(500).json({ error: 'Failed to fetch cache stats' }); + } +}); + // --- Notification & Alerting Routes --- app.get('/api/agents', async (req: Request, res: Response) => { @@ -5257,6 +5351,7 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { } const dockerController = DockerController.getInstance(req.nodeId); const results = await dockerController.removeContainers(containerIds); + invalidateNodeCaches(req.nodeId); res.json({ results }); } catch (error) { console.error('Failed to prune orphan containers:', error); @@ -5286,6 +5381,9 @@ app.post('/api/system/prune/system', async (req: Request, res: Response) => { result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes'); } + if (target === 'containers') { + invalidateNodeCaches(req.nodeId); + } res.json({ message: 'Prune completed', ...result }); } catch (error: any) { console.error('System prune error:', error); @@ -5508,6 +5606,7 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { try { const atomic = LicenseService.getInstance().getTier() === 'paid'; await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic); + invalidateNodeCaches(req.nodeId); res.json({ success: true, message: 'Template deployed successfully' }); } catch (deployError: any) { const rawError = deployError.message || String(deployError); @@ -5531,6 +5630,9 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { } } + // Partial state may linger (directory created, deploy failed, rollback + // may or may not have cleaned up). Drop node caches either way. + invalidateNodeCaches(req.nodeId); res.status(500).json({ error: parsed.message, rolledBack: shouldRollback, @@ -5578,59 +5680,60 @@ app.get('/api/image-updates/status', authMiddleware, (_req: Request, res: Respon }); // Fleet-wide image update aggregation (local DB + remote node APIs) -let fleetUpdateCache: { data: Record>; fetchedAt: number } | null = null; +const FLEET_UPDATE_CACHE_KEY = 'fleet-updates'; const FLEET_CACHE_TTL = 120_000; // 2 minutes app.get('/api/image-updates/fleet', authMiddleware, async (_req: Request, res: Response) => { try { - if (fleetUpdateCache && Date.now() - fleetUpdateCache.fetchedAt < FLEET_CACHE_TTL) { - res.json(fleetUpdateCache.data); - return; - } + const result = await CacheService.getInstance().getOrFetch>>( + FLEET_UPDATE_CACHE_KEY, + FLEET_CACHE_TTL, + async () => { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + const nr = NodeRegistry.getInstance(); + const data: Record> = {}; - const db = DatabaseService.getInstance(); - const nodes = db.getNodes(); - const nr = NodeRegistry.getInstance(); - const result: Record> = {}; - - // Local nodes: synchronous DB reads - for (const node of nodes) { - if (node.type === 'local') { - result[node.id] = db.getStackUpdateStatus(node.id); - } - } - - // Remote nodes: parallel fetches with individual timeouts - const remoteNodes = nodes.filter(n => n.type === 'remote' && n.status === 'online' && n.api_url); - const remoteResults = await Promise.allSettled( - remoteNodes.map(async (node) => { - const proxyTarget = nr.getProxyTarget(node.id); - const baseUrl = node.api_url!.replace(/\/$/, ''); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - try { - const resp = await fetch(`${baseUrl}/api/image-updates`, { - headers: proxyTarget?.apiToken - ? { Authorization: `Bearer ${proxyTarget.apiToken}` } - : {}, - signal: controller.signal, - }); - clearTimeout(timeout); - if (resp.ok) return { nodeId: node.id, data: await resp.json() as Record }; - } catch { - clearTimeout(timeout); + // Local nodes: synchronous DB reads + for (const node of nodes) { + if (node.type === 'local') { + data[node.id] = db.getStackUpdateStatus(node.id); + } } - return null; - }) + + // Remote nodes: parallel fetches with individual timeouts + const remoteNodes = nodes.filter(n => n.type === 'remote' && n.status === 'online' && n.api_url); + const remoteResults = await Promise.allSettled( + remoteNodes.map(async (node) => { + const proxyTarget = nr.getProxyTarget(node.id); + const baseUrl = node.api_url!.replace(/\/$/, ''); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const resp = await fetch(`${baseUrl}/api/image-updates`, { + headers: proxyTarget?.apiToken + ? { Authorization: `Bearer ${proxyTarget.apiToken}` } + : {}, + signal: controller.signal, + }); + clearTimeout(timeout); + if (resp.ok) return { nodeId: node.id, data: await resp.json() as Record }; + } catch { + clearTimeout(timeout); + } + return null; + }) + ); + + for (const entry of remoteResults) { + if (entry.status === 'fulfilled' && entry.value) { + data[entry.value.nodeId] = entry.value.data; + } + } + + return data; + }, ); - - for (const entry of remoteResults) { - if (entry.status === 'fulfilled' && entry.value) { - result[entry.value.nodeId] = entry.value.data; - } - } - - fleetUpdateCache = { data: result, fetchedAt: Date.now() }; res.json(result); } catch (error) { console.error('Failed to aggregate fleet update status:', error); @@ -5907,7 +6010,7 @@ app.delete('/api/nodes/:id', async (req: Request, res: Response) => { const id = parseInt(nodeIdParam); DatabaseService.getInstance().deleteNode(id); NodeRegistry.getInstance().evictConnection(id); - remoteMetaCache.delete(id); + CacheService.getInstance().invalidate(`${REMOTE_META_NAMESPACE}:${id}`); res.json({ success: true }); } catch (error: any) { console.error('Failed to delete node:', error); @@ -5928,9 +6031,11 @@ app.post('/api/nodes/:id/test', async (req: Request, res: Response) => { // Fetch capability metadata for a specific node. For local nodes, returns this // instance's capabilities directly. For remote nodes, relays GET /api/meta from -// the remote Sencho instance. Backend-side cache shields against rate limit -// contention on the remote; stale data is served on transient failures. -const remoteMetaCache = new Map(); +// the remote Sencho instance. Backend-side cache (via CacheService) shields +// against rate limit contention on the remote and serves stale data on +// transient failures. Keys are "remote-meta:" so we can invalidate by +// namespace when a node is deleted. +const REMOTE_META_NAMESPACE = 'remote-meta'; const REMOTE_META_CACHE_TTL = 3 * 60 * 1000; app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Response) => { @@ -5947,29 +6052,27 @@ app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Respons return; } - const cached = remoteMetaCache.get(id); - if (cached && Date.now() - cached.fetchedAt < REMOTE_META_CACHE_TTL) { - res.json(cached.data); - return; - } - const baseUrl = node.api_url?.replace(/\/$/, ''); if (!baseUrl || !node.api_token) { res.json({ version: null, capabilities: [] }); return; } - const meta = await fetchRemoteMeta(baseUrl, node.api_token); - - // A successful fetch always includes a version; null version means the remote - // was unreachable. Only cache successful responses so transient failures retry. - if (meta.version !== null) { - remoteMetaCache.set(id, { data: meta, fetchedAt: Date.now() }); - } else if (cached) { - cached.fetchedAt = Date.now(); - res.json(cached.data); - return; - } + const cacheKey = `${REMOTE_META_NAMESPACE}:${id}`; + const meta = await CacheService.getInstance().getOrFetch( + cacheKey, + REMOTE_META_CACHE_TTL, + async () => { + const fetched = await fetchRemoteMeta(baseUrl, node.api_token!); + // A successful fetch always includes a version; null version means the + // remote was unreachable. Throw so CacheService serves stale on error + // instead of caching an empty result. + if (fetched.version === null) { + throw new Error('Remote meta fetch returned null version'); + } + return fetched; + }, + ); res.json(meta); } catch (error: unknown) { diff --git a/backend/src/services/CacheService.ts b/backend/src/services/CacheService.ts new file mode 100644 index 00000000..7fa6aa18 --- /dev/null +++ b/backend/src/services/CacheService.ts @@ -0,0 +1,217 @@ +/** + * CacheService: a single in-process cache for the entire backend. + * + * Design goals (see plans/caching-strategy-audit.md for full context): + * - TTL-based entries. Entries expire after a configured ms window. + * - Inflight deduplication: concurrent getOrFetch() calls for the same key + * share one in-flight Promise, preventing thundering-herd on cache miss. + * - Stale-on-error fallback: if fetcher() rejects while a stale value + * exists, return the stale value (and count it as a "stale" hit). + * - Namespaced stats: per-namespace hit / miss / stale counters for + * observability, surfaced via /api/system/cache-stats. + * - Key conventions: use "namespace:subkey" for per-entity caches + * (e.g. "stats:1" for nodeId=1). Stats are aggregated by namespace. + * - Safety cap: a hard limit on total entries as a defense-in-depth + * guard against unbounded growth. Default 1000 entries; each cache + * used in Sencho is bounded by construction (singleton or per-nodeId). + * + * Non-goals: + * - LRU eviction. All current callers have bounded keyspaces. + * - Persistence. Caches are rebuilt on process restart. + * - Cross-process sync. Sencho runs single-process per instance. + */ + +interface CacheEntry { + value: T; + expiresAt: number; +} + +interface NamespaceStats { + hits: number; + misses: number; + stale: number; + size: number; +} + +const MAX_ENTRIES = 1000; + +/** Extract the namespace (part before first colon) from a key. */ +function namespaceOf(key: string): string { + const idx = key.indexOf(':'); + return idx === -1 ? key : key.slice(0, idx); +} + +export class CacheService { + private static instance: CacheService; + + private readonly store = new Map>(); + private readonly inflight = new Map>(); + private readonly stats = new Map(); + + public static getInstance(): CacheService { + if (!CacheService.instance) { + CacheService.instance = new CacheService(); + } + return CacheService.instance; + } + + /** + * Retrieve a cached value or compute it via `fetcher`. Concurrent callers + * for the same key await the same in-flight promise. + * + * On fetcher rejection: if a stale entry exists, return it (counted as + * `stale`); otherwise propagate the error. + */ + public async getOrFetch( + key: string, + ttlMs: number, + fetcher: () => Promise, + ): Promise { + const ns = namespaceOf(key); + const now = Date.now(); + const existing = this.store.get(key) as CacheEntry | undefined; + + if (existing && existing.expiresAt > now) { + this.recordHit(ns); + return existing.value; + } + + this.recordMiss(ns); + + const inflight = this.inflight.get(key) as Promise | undefined; + if (inflight) return inflight; + + const promise = (async () => { + try { + const value = await fetcher(); + this.set(key, value, ttlMs); + return value; + } catch (err) { + if (existing) { + this.recordStale(ns); + return existing.value; + } + throw err; + } finally { + this.inflight.delete(key); + } + })(); + + this.inflight.set(key, promise); + return promise; + } + + /** + * Synchronous get: returns undefined if the key is absent or expired. + * Updates hit/miss counters. + */ + public get(key: string): T | undefined { + const ns = namespaceOf(key); + const entry = this.store.get(key) as CacheEntry | undefined; + if (!entry || entry.expiresAt <= Date.now()) { + this.recordMiss(ns); + return undefined; + } + this.recordHit(ns); + return entry.value; + } + + /** + * Set a value with a TTL. If the total number of entries exceeds + * MAX_ENTRIES, the oldest expired entries are purged first; if still + * over cap, the insertion is rejected with a warning (defense in depth). + */ + public set(key: string, value: T, ttlMs: number): void { + if (this.store.size >= MAX_ENTRIES && !this.store.has(key)) { + this.purgeExpired(); + if (this.store.size >= MAX_ENTRIES) { + console.warn(`[CacheService] Entry cap reached (${MAX_ENTRIES}); refusing to cache "${key}"`); + return; + } + } + this.store.set(key, { value, expiresAt: Date.now() + ttlMs }); + } + + /** Invalidate a single key. */ + public invalidate(key: string): void { + this.store.delete(key); + } + + /** Invalidate every key whose namespace matches `namespace`. */ + public invalidateNamespace(namespace: string): void { + const prefix = `${namespace}:`; + for (const key of this.store.keys()) { + if (key === namespace || key.startsWith(prefix)) { + this.store.delete(key); + } + } + } + + /** Reset all state. Intended for tests and admin "flush" actions. */ + public flush(): void { + this.store.clear(); + this.inflight.clear(); + this.stats.clear(); + } + + /** + * Per-namespace statistics snapshot. Size counts live (non-expired) entries + * currently in the store for each namespace at call time. + */ + public getStats(): Record { + // Recompute live sizes at snapshot time; counters are kept incrementally. + const sizes = new Map(); + const now = Date.now(); + for (const [key, entry] of this.store) { + if (entry.expiresAt <= now) continue; + const ns = namespaceOf(key); + sizes.set(ns, (sizes.get(ns) ?? 0) + 1); + } + + const result: Record = {}; + const allNs = new Set([...this.stats.keys(), ...sizes.keys()]); + for (const ns of allNs) { + const base = this.stats.get(ns) ?? { hits: 0, misses: 0, stale: 0, size: 0 }; + result[ns] = { + hits: base.hits, + misses: base.misses, + stale: base.stale, + size: sizes.get(ns) ?? 0, + }; + } + return result; + } + + // ─── internals ──────────────────────────────────────────────────────── + + private recordHit(namespace: string): void { + const s = this.getOrCreateNs(namespace); + s.hits += 1; + } + + private recordMiss(namespace: string): void { + const s = this.getOrCreateNs(namespace); + s.misses += 1; + } + + private recordStale(namespace: string): void { + const s = this.getOrCreateNs(namespace); + s.stale += 1; + } + + private getOrCreateNs(namespace: string): NamespaceStats { + let s = this.stats.get(namespace); + if (!s) { + s = { hits: 0, misses: 0, stale: 0, size: 0 }; + this.stats.set(namespace, s); + } + return s; + } + + private purgeExpired(): void { + const now = Date.now(); + for (const [key, entry] of this.store) { + if (entry.expiresAt <= now) this.store.delete(key); + } + } +} diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 5d4c8472..d1c87ef4 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -7,6 +7,7 @@ import fs from 'fs/promises'; import * as yaml from 'yaml'; import { NodeRegistry } from './NodeRegistry'; +import { CacheService } from './CacheService'; import { isPathWithinBase } from '../utils/validation'; const execAsync = promisify(exec); @@ -17,7 +18,7 @@ const COMPOSE_FILE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml' /** Cached mapping from compose `name:` field to stack directory name. TTL-based to avoid re-parsing YAML on every poll. */ const PROJECT_NAME_CACHE_TTL_MS = 60_000; -let projectNameCache: { map: Record; builtAt: number } | null = null; +const PROJECT_NAME_CACHE_KEY = 'project-name-map'; /** Common web-UI private ports, checked in priority order when detecting the main app port. */ const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000]; @@ -573,36 +574,37 @@ class DockerController { * Compose files with a top-level `name:` field override the default project name. */ private static async resolveProjectNameMap(stackNames: string[]): Promise> { - if (projectNameCache && Date.now() - projectNameCache.builtAt < PROJECT_NAME_CACHE_TTL_MS) { - return projectNameCache.map; - } + return CacheService.getInstance().getOrFetch( + PROJECT_NAME_CACHE_KEY, + PROJECT_NAME_CACHE_TTL_MS, + async () => { + const map: Record = {}; - const map: Record = {}; + await Promise.all(stackNames.map(async (stackDir) => { + map[stackDir] = stackDir; - await Promise.all(stackNames.map(async (stackDir) => { - map[stackDir] = stackDir; - - for (const fileName of COMPOSE_FILE_NAMES) { - const filePath = path.join(COMPOSE_DIR, stackDir, fileName); - try { - const content = await fs.readFile(filePath, 'utf-8'); - const parsed = yaml.parse(content); - if (parsed?.name && typeof parsed.name === 'string') { - map[parsed.name] = stackDir; + for (const fileName of COMPOSE_FILE_NAMES) { + const filePath = path.join(COMPOSE_DIR, stackDir, fileName); + try { + const content = await fs.readFile(filePath, 'utf-8'); + const parsed = yaml.parse(content); + if (parsed?.name && typeof parsed.name === 'string') { + map[parsed.name] = stackDir; + } + break; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + console.error(`[DockerController] Failed to read ${filePath}:`, err); + break; + } + } } - break; - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException)?.code; - if (code !== 'ENOENT' && code !== 'ENOTDIR') { - console.error(`[DockerController] Failed to read ${filePath}:`, err); - break; - } - } - } - })); + })); - projectNameCache = { map, builtAt: Date.now() }; - return map; + return map; + }, + ); } public async getBulkStackStatuses(stackNames: string[]): Promise> { diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index 5ad947ea..46bc7432 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { DatabaseService } from './DatabaseService'; +import { CacheService } from './CacheService'; export interface TemplateEnv { @@ -191,76 +192,67 @@ function getCategoriesForApp(name: string): string[] { } export class TemplateService { - private cachedTemplates: Template[] = []; - private lastFetchTime: number = 0; + private static readonly CACHE_KEY = 'templates:all'; private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours public clearCache(): void { - this.cachedTemplates = []; - this.lastFetchTime = 0; + CacheService.getInstance().invalidate(TemplateService.CACHE_KEY); } public async getTemplates(): Promise { - const now = Date.now(); - if (this.cachedTemplates.length > 0 && now - this.lastFetchTime < this.CACHE_DURATION_MS) { - return this.cachedTemplates; - } - try { - const settings = DatabaseService.getInstance().getGlobalSettings(); - // Default to a reliable LSIO Portainer v2 template registry if not set - const registryUrl = settings.template_registry_url || 'https://api.linuxserver.io/api/v1/images?include_config=true'; + return await CacheService.getInstance().getOrFetch( + TemplateService.CACHE_KEY, + this.CACHE_DURATION_MS, + async () => { + const settings = DatabaseService.getInstance().getGlobalSettings(); + // Default to a reliable LSIO Portainer v2 template registry if not set + const registryUrl = settings.template_registry_url || 'https://api.linuxserver.io/api/v1/images?include_config=true'; - const response = await axios.get(registryUrl); + const response = await axios.get(registryUrl); - if (registryUrl.includes('api.linuxserver.io')) { - // Official LSIO API Schema Mapping - const lsioApps = response.data?.data?.repositories?.linuxserver || []; + if (registryUrl.includes('api.linuxserver.io')) { + // Official LSIO API Schema Mapping + const lsioApps = response.data?.data?.repositories?.linuxserver || []; - this.cachedTemplates = Object.values(lsioApps).map((app: any) => { - return { - type: 1, - title: app.name, - description: app.description || '', - logo: app.logo || `https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/${app.name}-logo.png`, - image: `lscr.io/linuxserver/${app.name}:latest`, - github_url: app.github, - docs_url: app.readme, - architectures: app.arch, - stars: app.stars, - categories: getCategoriesForApp(app.name), - source: 'linuxserver', - // Map configs if available, otherwise default to empty arrays - ports: (app.config?.ports || []).map((p: any) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`), - volumes: (app.config?.volumes || []).map((v: any) => { - const folderName = v.path.split('/').filter(Boolean).pop() || 'data'; - return { - container: v.path, - bind: `./${folderName}` // Proactively create a clean relative path - }; - }), - env: (app.config?.environment || []).map((e: any) => ({ - name: e.name, - label: e.desc || e.name, - default: e.default || '' - })) - }; - }); - } else { - // Legacy Portainer v2 Format (Fallback for custom registries) - // The Portainer v2 spec includes a native `categories` field - pass it through. - this.cachedTemplates = (response.data.templates || []) - .filter((t: Template) => !!t.image && t.type === 1) - .map((t: Template) => ({ ...t, source: 'custom' })); - } + return Object.values(lsioApps).map((app: any) => ({ + type: 1, + title: app.name, + description: app.description || '', + logo: app.logo || `https://raw.githubusercontent.com/linuxserver/docker-templates/master/linuxserver.io/img/${app.name}-logo.png`, + image: `lscr.io/linuxserver/${app.name}:latest`, + github_url: app.github, + docs_url: app.readme, + architectures: app.arch, + stars: app.stars, + categories: getCategoriesForApp(app.name), + source: 'linuxserver', + // Map configs if available, otherwise default to empty arrays + ports: (app.config?.ports || []).map((p: any) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`), + volumes: (app.config?.volumes || []).map((v: any) => { + const folderName = v.path.split('/').filter(Boolean).pop() || 'data'; + return { + container: v.path, + bind: `./${folderName}` // Proactively create a clean relative path + }; + }), + env: (app.config?.environment || []).map((e: any) => ({ + name: e.name, + label: e.desc || e.name, + default: e.default || '' + })) + })); + } - this.lastFetchTime = now; - return this.cachedTemplates; + // Legacy Portainer v2 Format (Fallback for custom registries) + // The Portainer v2 spec includes a native `categories` field - pass it through. + return (response.data.templates || []) + .filter((t: Template) => !!t.image && t.type === 1) + .map((t: Template) => ({ ...t, source: 'custom' })); + }, + ); } catch (error) { console.error('Failed to fetch templates', error); - if (this.cachedTemplates.length > 0) { - return this.cachedTemplates; - } throw new Error('Could not fetch templates from registry'); } } diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index a65f7bc9..0f017685 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -10,6 +10,9 @@ export default defineConfig({ pool: 'forks', // Timeout generous for DB init and HTTP calls. testTimeout: 15_000, + // beforeAll hooks that import the full Express stack can exceed the + // default 10s hook timeout under CPU contention from parallel fork workers. + hookTimeout: 15_000, // Sequential within each file (DB state is shared per file). sequence: { concurrent: false }, }, diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index a4e17e46..56623994 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -486,6 +486,16 @@ docker compose pull && docker compose up -d --- +## Dashboard stats briefly lag behind external changes + +**Symptom:** You start, stop, or restart a container directly via `docker` CLI (not through Sencho), and the Sencho dashboard takes a couple of seconds to reflect the change. + +**Cause:** Sencho caches expensive dashboard queries (container counts, stack statuses, host CPU/memory) for a few seconds to keep the UI responsive under heavy polling. Changes made *inside* Sencho invalidate the cache immediately, so your own actions are always reflected instantly. Changes made *outside* Sencho (e.g. another `docker compose up` on the host) fall through the cache window. + +**Fix:** Wait a few seconds, or switch tabs and back to force a fresh read. This is working as intended and has no effect on correctness, only on freshness. + +--- + ## Checking the health endpoint Sencho exposes a health endpoint for monitoring and container health checks: