mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
9f2f13f35a
* feat(stack-files): in-process metrics and structured mutation logs
Adds FileExplorerMetricsService, an in-memory counter and latency
histogram keyed by (nodeId, op) modelled on StackOpMetricsService.
record() is called once per file-route request from a small
recordFileOp helper that wraps the metric capture; rejection paths
that ran real filesystem work (overwrite confirms, write conflicts,
multer oversize) now record an error count and a warn log instead of
disappearing from the snapshot. recordUploadBytes tracks bytes that
actually persisted so a node taking many small uploads vs a few large
ones is visible in the dashboard.
Admin-only GET /api/file-explorer-metrics returns the snapshot in the
same shape as /api/stack-metrics so an operator chasing a slow node
has a single place to look. No external telemetry; everything is
process-local and resets on restart.
Mutation INFO lines now carry op, stack, path, and bytes/mode/
recursive/overwrite/toPath in the structured details so log scrapers
can pivot on the same identity the metric uses. The existing
developer_mode gate on logFileDiag is unchanged. A new
rejectFileMutation helper centralises the log+metric+response triple
on the three rejection sites (upload DIR_EXISTS, upload FILE_EXISTS,
write PRECONDITION_FAILED) so a future rejection cannot skip the
metric.
Tests cover the service in isolation (counts, p50/p95, ring buffer cap,
upload bytes tracking, snapshot sorting), the admin route auth and
shape, and the route layer end-to-end: a real upload surfaces in
/api/file-explorer-metrics, a FILE_EXISTS rejection bumps errorCount,
and the structured INFO line carries the expected fields.
* fix(stack-files): tighten download/upload latency tracking
Two metric-accuracy bugs caught in independent review:
Download metric was recorded as a success before result.stream.pipe(res)
ran. A mid-stream read failure or a client disconnect was not counted as
an error because the recorder fired at pipe time, not stream completion.
Now the recorder hangs off res.on('finish') for success and on both the
stream's error event and res.on('close') for failure, with a flag so a
normal completion (which emits both finish and close) does not produce
two recordings.
Upload latency was inconsistent across the success and failure branches.
The multer wrapper captured startedAt at route entry, but the async
handler created its own startedAt after multer had already buffered the
body. Successful uploads therefore reported only the post-multer time
and the multipart transfer/buffer cost vanished from the histogram. The
wrapper now stashes the route-entry timestamp on the request object and
the async handler reads it back, so every metric for a given upload
shares one window.
A new test pins the download recorder behaviour: a single successful
download must produce successCount=1, count=1, errorCount=0 in the
snapshot, which would have flagged the original double-fire path the
finish+close pair could have introduced.
182 lines
8.0 KiB
TypeScript
182 lines
8.0 KiB
TypeScript
import express, { Request, Response } from 'express';
|
|
import './types/express';
|
|
import { authGate, auditLog } from './middleware/authGate';
|
|
import { enforceApiTokenScope } from './middleware/apiTokenScope';
|
|
import { hubOnlyGuard } from './middleware/hubOnlyGuard';
|
|
import { errorHandler } from './middleware/errorHandler';
|
|
import { createApp } from './app';
|
|
import { createRemoteProxyMiddleware } from './proxy/remoteNodeProxy';
|
|
import { createServer } from './server';
|
|
import { attachUpgrade } from './websocket/upgradeHandler';
|
|
import { startServer } from './bootstrap/startup';
|
|
import { installShutdownHandlers } from './bootstrap/shutdown';
|
|
import { metaRouter } from './routes/meta';
|
|
import { blueprintsRouter } from './routes/blueprints';
|
|
import { nodeLabelsRouter } from './routes/nodeLabels';
|
|
import { authRouter } from './routes/auth';
|
|
import { mfaRouter } from './routes/mfa';
|
|
import { ssoRouter } from './routes/sso';
|
|
import { licenseRouter, systemUpdateRouter } from './routes/license';
|
|
import { webhooksRouter } from './routes/webhooks';
|
|
import { usersRouter } from './routes/users';
|
|
import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources';
|
|
import { fleetRouter } from './routes/fleet';
|
|
import { fleetActionsRouter } from './routes/fleetActions';
|
|
import { cloudBackupRouter } from './routes/cloudBackup';
|
|
import { permissionsRouter } from './routes/permissions';
|
|
import { convertRouter } from './routes/convert';
|
|
import { alertsRouter } from './routes/alerts';
|
|
import { labelsRouter, stackLabelsRouter } from './routes/labels';
|
|
import { apiTokensRouter } from './routes/apiTokens';
|
|
import { auditLogRouter } from './routes/auditLog';
|
|
import { settingsRouter } from './routes/settings';
|
|
import { scheduledTasksRouter } from './routes/scheduledTasks';
|
|
import { meshRouter } from './routes/mesh';
|
|
import { agentsRouter } from './routes/agents';
|
|
import { metricsRouter } from './routes/metrics';
|
|
import { imageUpdatesRouter, autoUpdateRouter } from './routes/imageUpdates';
|
|
import { autoHealRouter } from './routes/autoHeal';
|
|
import { notificationsRouter, notificationRoutesRouter } from './routes/notifications';
|
|
import { consoleRouter } from './routes/console';
|
|
import { ssoConfigRouter } from './routes/ssoConfig';
|
|
import { registriesRouter } from './routes/registries';
|
|
import { systemMaintenanceRouter } from './routes/systemMaintenance';
|
|
import { volumesRouter } from './routes/volumes';
|
|
import { templatesRouter } from './routes/templates';
|
|
import { securityRouter } from './routes/security';
|
|
import { dashboardRouter } from './routes/dashboard';
|
|
import { containersRouter, portsRouter } from './routes/containers';
|
|
import { nodesRouter } from './routes/nodes';
|
|
import { stacksRouter } from './routes/stacks';
|
|
import { stackActivityRouter } from './routes/stackActivity';
|
|
import { stackMetricsRouter } from './routes/stackMetrics';
|
|
import { fileExplorerMetricsRouter } from './routes/fileExplorerMetrics';
|
|
import { secretsRouter } from './routes/secrets';
|
|
|
|
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
|
|
// util._extend internally. The warning fires at runtime when createProxyServer() is
|
|
// first invoked (NOT at import time), so intercepting process.emitWarning here -
|
|
// before the proxy instances are created below - fully prevents it.
|
|
// http-proxy has no compatible update; this suppression is intentional and safe.
|
|
const _origEmitWarning = process.emitWarning.bind(process);
|
|
(process as any).emitWarning = (warning: any, ...args: any[]) => {
|
|
const code = typeof args[0] === 'object' ? args[0]?.code : args[1];
|
|
if (code === 'DEP0060') return;
|
|
_origEmitWarning(warning, ...args);
|
|
};
|
|
|
|
const app = createApp();
|
|
|
|
// Public /api/health and /api/meta (no auth). Mounted before authGate.
|
|
app.use('/api', metaRouter);
|
|
|
|
// Auth / MFA / SSO routers. Mounted before authGate because some paths are
|
|
// public (login, setup, SSO callbacks); handlers that need auth use
|
|
// authMiddleware directly.
|
|
app.use('/api/auth', authRouter);
|
|
app.use('/api/auth', mfaRouter);
|
|
app.use('/api/auth/sso', ssoRouter);
|
|
|
|
// Auth gate on all /api/* routes (exempts /auth/* and webhook triggers).
|
|
app.use('/api', authGate);
|
|
|
|
// Audit-log every mutating /api/* action (POST/PUT/DELETE/PATCH).
|
|
app.use('/api', auditLog);
|
|
|
|
app.use('/api', enforceApiTokenScope);
|
|
|
|
// Hub-only guard: reject requests whose nodeId resolves to a remote node
|
|
// when the path is hub-only (e.g. /api/scheduled-tasks, /api/audit-log,
|
|
// /api/notification-routes). Without this, the proxy would forward the
|
|
// request and process it on the remote as a local call, crossing a
|
|
// node-authority boundary that the UI hides. See helpers/proxyExemptPaths.ts
|
|
// for the prefix list and middleware/hubOnlyGuard.ts for the rationale.
|
|
app.use('/api', hubOnlyGuard);
|
|
|
|
// Remote Node HTTP Proxy (see proxy/remoteNodeProxy.ts). Mounted BEFORE the
|
|
// per-group routers so a request targeting a remote node short-circuits into
|
|
// the proxy instead of hitting a local handler that would read local state.
|
|
// Gateway-level paths (auth, nodes, license, fleet, webhooks, meta) are listed
|
|
// in helpers/proxyExemptPaths.ts and bypass the proxy back to the local
|
|
// handlers below.
|
|
app.use('/api/', createRemoteProxyMiddleware());
|
|
|
|
app.use('/api/license', licenseRouter);
|
|
app.use('/api/system', systemUpdateRouter);
|
|
app.use('/api/permissions', permissionsRouter);
|
|
app.use('/api/convert', convertRouter);
|
|
app.use('/api/alerts', alertsRouter);
|
|
app.use('/api/labels', labelsRouter);
|
|
app.use('/api/stacks', stackLabelsRouter);
|
|
app.use('/api/secrets', secretsRouter);
|
|
app.use('/api/api-tokens', apiTokensRouter);
|
|
app.use('/api/audit-log', auditLogRouter);
|
|
app.use('/api/fleet', fleetRouter);
|
|
app.use('/api/fleet-actions', fleetActionsRouter);
|
|
app.use('/api/cloud-backup', cloudBackupRouter);
|
|
app.use('/api/webhooks', webhooksRouter);
|
|
app.use('/api/users', usersRouter);
|
|
app.use('/api/git-sources', gitSourcesRouter);
|
|
app.use('/api/stacks', stackGitSourceRouter);
|
|
app.use('/api/settings', settingsRouter);
|
|
app.use('/api/scheduled-tasks', scheduledTasksRouter);
|
|
app.use('/api/mesh', meshRouter);
|
|
app.use('/api/blueprints', blueprintsRouter);
|
|
app.use('/api/node-labels', nodeLabelsRouter);
|
|
app.use('/api/agents', agentsRouter);
|
|
app.use('/api', metricsRouter);
|
|
app.use('/api/image-updates', imageUpdatesRouter);
|
|
app.use('/api/auto-update', autoUpdateRouter);
|
|
app.use('/api/auto-heal', autoHealRouter);
|
|
app.use('/api/notifications', notificationsRouter);
|
|
app.use('/api/notification-routes', notificationRoutesRouter);
|
|
app.use('/api/system', consoleRouter);
|
|
app.use('/api/sso/config', ssoConfigRouter);
|
|
app.use('/api/registries', registriesRouter);
|
|
app.use('/api/system', systemMaintenanceRouter);
|
|
app.use('/api/volumes', volumesRouter);
|
|
app.use('/api/templates', templatesRouter);
|
|
app.use('/api/security', securityRouter);
|
|
app.use('/api/containers', containersRouter);
|
|
app.use('/api/ports', portsRouter);
|
|
app.use('/api/dashboard', dashboardRouter);
|
|
app.use('/api/nodes', nodesRouter);
|
|
app.use('/api/stacks', stackActivityRouter);
|
|
app.use('/api/stacks', stacksRouter);
|
|
app.use('/api/stack-metrics', stackMetricsRouter);
|
|
app.use('/api/file-explorer-metrics', fileExplorerMetricsRouter);
|
|
|
|
const { server, wss, pilotTunnelWss } = createServer(app);
|
|
attachUpgrade(server, { wss, pilotTunnelWss });
|
|
|
|
// Static / SPA fallback. Production serves the built frontend; dev returns a
|
|
// JSON 404 for unmatched /api paths to prevent fetch hangs.
|
|
if (process.env.NODE_ENV === 'production') {
|
|
app.use(express.static('public'));
|
|
app.use((req: Request, res: Response) => {
|
|
if (!req.path.startsWith('/api')) {
|
|
res.sendFile('index.html', { root: 'public' });
|
|
} else {
|
|
res.status(404).json({ error: 'API endpoint not found' });
|
|
}
|
|
});
|
|
} else {
|
|
app.use((req: Request, res: Response) => {
|
|
if (req.path.startsWith('/api')) {
|
|
res.status(404).json({ error: 'API endpoint not found' });
|
|
}
|
|
});
|
|
}
|
|
|
|
// Central error handler: must be registered after all routes and static.
|
|
app.use(errorHandler);
|
|
|
|
installShutdownHandlers(server);
|
|
|
|
if (require.main === module) {
|
|
void startServer(server);
|
|
}
|
|
|
|
// Exports used by tests (supertest requires the http.Server instance).
|
|
export { app, server };
|