mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
69edb0dcbb
* fix(observability): gate global logs to admins, scope to managed containers, harden SSE Make the Logs feed an administrator view enforced on both sides (requireAdmin on the /api/logs/global poll and SSE routes; the Logs nav item plus a redirect guard on the frontend), and scope the feed to Sencho-managed containers only via a shared isManagedByComposeDir helper that /stats now reuses. Harden the SSE stream: a stateful frame demuxer that survives chunk boundaries so a Docker frame split across reads is reassembled instead of dropped or garbled; a per-stream error listener so one broken follow stream cannot crash the event loop (it posts a single degraded notice and keeps the others alive); a cap on concurrent follow streams with a truncation notice; a bounded initial tail; and backpressure that pauses the source streams when the client is slow and resumes on drain. Bound the polling snapshot's per-container fan-out with a concurrency limit. Add process-local, in-memory log-stream counters exposed at the admin-only /api/system/log-stream-metrics endpoint (active connections, lines streamed, attach and frame errors). Collapse the view to the local hub and remove the dead remote-node handling. * fix(observability): close remote-proxy bypass of the global-logs admin gate The logs feed's requireAdmin lives in the local route handler, which the remote proxy skips when forwarding a request whose nodeId targets a remote node. A hub user could therefore request /api/logs/global*, /api/logs/global/stream, or /api/system/log-stream-metrics with x-node-id (or ?nodeId= for the SSE transport) pointing at a remote node and have it served as the node-proxy admin on the far side, sidestepping the gate entirely. Add these paths to HUB_ONLY_PREFIXES so hubOnlyGuard rejects a remote nodeId with 403 before the proxy runs, matching the existing protection on audit-log, scheduled-tasks, and notification-routes. Add regression tests covering the collection path, the SSE sub-path (both the x-node-id header and the ?nodeId= query transport), and the stream-metrics endpoint.
72 lines
2.6 KiB
TypeScript
72 lines
2.6 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { GlobalLogsMetrics } from '../services/GlobalLogsMetrics';
|
|
import { mapWithConcurrency } from '../routes/metrics';
|
|
|
|
describe('GlobalLogsMetrics', () => {
|
|
beforeEach(() => GlobalLogsMetrics.resetForTests());
|
|
|
|
it('starts at all-zero', () => {
|
|
const s = GlobalLogsMetrics.snapshot();
|
|
expect(s.active_sse_connections).toBe(0);
|
|
expect(s.sse_connections_total).toBe(0);
|
|
expect(s.lines_streamed_total).toBe(0);
|
|
});
|
|
|
|
it('openConnection bumps both the gauge and the total; closeConnection drops only the gauge', () => {
|
|
GlobalLogsMetrics.openConnection();
|
|
GlobalLogsMetrics.openConnection();
|
|
expect(GlobalLogsMetrics.snapshot().active_sse_connections).toBe(2);
|
|
expect(GlobalLogsMetrics.snapshot().sse_connections_total).toBe(2);
|
|
|
|
GlobalLogsMetrics.closeConnection();
|
|
expect(GlobalLogsMetrics.snapshot().active_sse_connections).toBe(1);
|
|
// The monotonic total is untouched by a close.
|
|
expect(GlobalLogsMetrics.snapshot().sse_connections_total).toBe(2);
|
|
});
|
|
|
|
it('clamps the active gauge at zero on an unbalanced close', () => {
|
|
GlobalLogsMetrics.closeConnection();
|
|
GlobalLogsMetrics.closeConnection();
|
|
expect(GlobalLogsMetrics.snapshot().active_sse_connections).toBe(0);
|
|
});
|
|
|
|
it('increments a monotonic counter by an explicit amount', () => {
|
|
GlobalLogsMetrics.increment('lines_streamed_total', 5);
|
|
GlobalLogsMetrics.increment('lines_streamed_total');
|
|
expect(GlobalLogsMetrics.snapshot().lines_streamed_total).toBe(6);
|
|
});
|
|
|
|
it('snapshot returns a copy, not a live reference', () => {
|
|
const s = GlobalLogsMetrics.snapshot();
|
|
s.lines_streamed_total = 999;
|
|
expect(GlobalLogsMetrics.snapshot().lines_streamed_total).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('mapWithConcurrency', () => {
|
|
it('runs every item exactly once', async () => {
|
|
const seen: number[] = [];
|
|
await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (n) => { seen.push(n); });
|
|
expect(seen.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
|
|
});
|
|
|
|
it('never exceeds the concurrency limit in flight', async () => {
|
|
let inFlight = 0;
|
|
let peak = 0;
|
|
await mapWithConcurrency([...Array(20).keys()], 4, async () => {
|
|
inFlight += 1;
|
|
peak = Math.max(peak, inFlight);
|
|
await new Promise(r => setTimeout(r, 2));
|
|
inFlight -= 1;
|
|
});
|
|
expect(peak).toBeLessThanOrEqual(4);
|
|
expect(peak).toBeGreaterThan(1); // proves it actually parallelizes
|
|
});
|
|
|
|
it('is a no-op on an empty array', async () => {
|
|
let calls = 0;
|
|
await mapWithConcurrency([], 4, async () => { calls += 1; });
|
|
expect(calls).toBe(0);
|
|
});
|
|
});
|