mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
perf: unify caching behind a single CacheService and enable HTTP compression (#468)
Replaces five ad-hoc in-process caches (project name map, templates, latest version, fleet update status, remote node meta) with a single internal CacheService that provides TTL, inflight-promise deduplication to protect against thundering herd, stale-on-error fallback, and per-namespace hit/miss/stale/size counters for observability. Wraps the hot-path dashboard endpoints in the cache with write-path invalidation: /api/stats (2s), /api/system/stats (3s), and /api/stacks/statuses (3s). Keys are namespaced by nodeId so switching nodes never serves another node's data. Every route that mutates container or stack state calls invalidateNodeCaches(nodeId), which also drops the global project-name-map, so user actions stay instantly reflected in the UI. For /api/system/stats the cheap per-request network rx/tx block is kept outside the cache so live-updating charts stay smooth while the expensive systeminformation.currentLoad() CPU sample (~200ms) is reused across the TTL. Adds admin-only GET /api/system/cache-stats returning per-namespace counters for operators who want to observe cache effectiveness. Enables the compression middleware site-wide for JSON responses. Large payloads like /api/templates shrink roughly 5x on the wire. SSE endpoints are explicitly excluded via a Content-Type filter so live log tails and metric streams are not buffered. Bumps vitest hookTimeout to match testTimeout (15s) so parallel fork workers do not hit the default 10s hook limit under CPU contention. Adds 35 new tests (26 unit for CacheService, 9 integration for cached endpoints) covering TTL expiry, inflight dedup, stale-on-error, namespace invalidation, entry-cap safety guard, and write-path invalidation end-to-end through Express routes.
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+75
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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: [{}, {}] });
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof obj>('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<string>((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<number>('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<string>('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<string>('bulk:5')).toBe('updated');
|
||||
});
|
||||
});
|
||||
});
|
||||
+248
-145
@@ -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:<nodeId>", "system-stats:<nodeId>", "stack-statuses:<nodeId>".
|
||||
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<string | null> | 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<string | null> {
|
||||
@@ -1267,7 +1306,7 @@ async function fetchFromDockerHub(): Promise<string | null> {
|
||||
return tags[0];
|
||||
}
|
||||
|
||||
async function fetchLatestSenchoVersion(): Promise<string | null> {
|
||||
async function fetchLatestSenchoVersion(): Promise<string> {
|
||||
try {
|
||||
const gh = await fetchFromGitHub();
|
||||
if (gh) return gh;
|
||||
@@ -1276,31 +1315,29 @@ async function fetchLatestSenchoVersion(): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<string>(
|
||||
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<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number }> = {};
|
||||
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<string, { status: 'running' | 'exited' | 'unknown'; mainPort?: number }> = {};
|
||||
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<number, Record<string, boolean>>; 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<Record<number, Record<string, boolean>>>(
|
||||
FLEET_UPDATE_CACHE_KEY,
|
||||
FLEET_CACHE_TTL,
|
||||
async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
const nr = NodeRegistry.getInstance();
|
||||
const data: Record<number, Record<string, boolean>> = {};
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
const nr = NodeRegistry.getInstance();
|
||||
const result: Record<number, Record<string, boolean>> = {};
|
||||
|
||||
// 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<string, boolean> };
|
||||
} 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<string, boolean> };
|
||||
} 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<number, { data: RemoteMeta; fetchedAt: number }>();
|
||||
// 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:<nodeId>" 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<RemoteMeta>(
|
||||
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) {
|
||||
|
||||
@@ -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<T> {
|
||||
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<string, CacheEntry<unknown>>();
|
||||
private readonly inflight = new Map<string, Promise<unknown>>();
|
||||
private readonly stats = new Map<string, NamespaceStats>();
|
||||
|
||||
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<T>(
|
||||
key: string,
|
||||
ttlMs: number,
|
||||
fetcher: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ns = namespaceOf(key);
|
||||
const now = Date.now();
|
||||
const existing = this.store.get(key) as CacheEntry<T> | undefined;
|
||||
|
||||
if (existing && existing.expiresAt > now) {
|
||||
this.recordHit(ns);
|
||||
return existing.value;
|
||||
}
|
||||
|
||||
this.recordMiss(ns);
|
||||
|
||||
const inflight = this.inflight.get(key) as Promise<T> | 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<T>(key: string): T | undefined {
|
||||
const ns = namespaceOf(key);
|
||||
const entry = this.store.get(key) as CacheEntry<T> | 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<T>(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<string, NamespaceStats> {
|
||||
// Recompute live sizes at snapshot time; counters are kept incrementally.
|
||||
const sizes = new Map<string, number>();
|
||||
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<string, NamespaceStats> = {};
|
||||
const allNs = new Set<string>([...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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>; 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<Record<string, string>> {
|
||||
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<string, string> = {};
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
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<Record<string, BulkStackInfo>> {
|
||||
|
||||
@@ -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<Template[]> {
|
||||
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<Template[]>(
|
||||
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<any>(registryUrl);
|
||||
const response = await axios.get<any>(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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user