mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(onboarding): add first-run environment checker (#1290)
* feat(onboarding): add first-run environment checker Add a preflight that checks whether the host can run Docker deploys before a deploy fails for an avoidable reason. It verifies the Docker engine is reachable and permitted, the Compose plugin is present, the compose directory is writable and mounted at a matching host path, the dashboard is behind TLS, and the compose volume has disk headroom. Each result that needs attention carries a specific fix rather than a generic error, and the checks never block: an operator who knows their setup can continue. The checks run as the final step of first-boot setup and can be re-run any time from the Recovery settings tab. A new admin-only endpoint, GET /api/diagnostics/environment, backs both surfaces. * fix(onboarding): distinguish unverified path mapping and support parent binds Treat a container whose self-inspect fails as an unverified path-mapping warning instead of a false "not containerized" pass, so an unverifiable mapping never reads as healthy. Resolve the compose directory through the longest-prefix bind mount and compare the host path it resolves to, so a parent bind such as -v /opt:/opt correctly covers COMPOSE_DIR=/opt/compose instead of warning that the directory is not bind-mounted. * test(e2e): advance the setup wizard past the environment step in loginAs The first-run setup helper clicked "Initialize console" and immediately waited for the dashboard, but setup now shows an environment-preflight step before landing the console. Click "Enter Sencho" to complete onboarding before asserting the dashboard, so the first test on a fresh instance passes.
This commit is contained in:
@@ -63,3 +63,28 @@ describe('GET /api/diagnostics', () => {
|
||||
expect(res.body.config.cloud_backup_secret_key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/diagnostics/environment', () => {
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/diagnostics/environment');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a non-admin', async () => {
|
||||
const res = await request(app).get('/api/diagnostics/environment').set('Authorization', viewerAuthHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('returns the environment report for an admin', async () => {
|
||||
const res = await request(app).get('/api/diagnostics/environment').set('Authorization', adminAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.checks)).toBe(true);
|
||||
const ids = (res.body.checks as Array<{ id: string }>).map(c => c.id);
|
||||
expect(ids).toEqual(['docker_socket', 'docker_compose', 'compose_dir', 'path_mapping', 'tls', 'disk_space']);
|
||||
for (const c of res.body.checks as Array<{ status: string; detail: string }>) {
|
||||
expect(['pass', 'warn', 'fail']).toContain(c.status);
|
||||
expect(typeof c.detail).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Unit tests for EnvironmentCheckService.collectEnvironmentReport: the mapping
|
||||
* from injected probe results to check rows, the per-check verdicts, and the
|
||||
* guarantee that every non-pass row carries actionable remediation. IO is
|
||||
* stubbed, so these run without a Docker daemon or filesystem.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
collectEnvironmentReport,
|
||||
pickBackingMount,
|
||||
type EnvironmentProbes,
|
||||
type EnvironmentCheck,
|
||||
} from '../services/EnvironmentCheckService';
|
||||
|
||||
function baseProbes(overrides: Partial<EnvironmentProbes> = {}): EnvironmentProbes {
|
||||
return {
|
||||
proto: 'https',
|
||||
host: 'sencho.example.com',
|
||||
composeDir: '/app/compose',
|
||||
pingDocker: async () => { /* reachable */ },
|
||||
composeVersion: async () => 'v2.29.0',
|
||||
accessDir: async () => ({ exists: true, isDir: true, writable: true }),
|
||||
bindMounts: async () => [{ source: '/app/compose', destination: '/app/compose' }],
|
||||
diskUsage: async () => ({ usePercent: 40, freeBytes: 50 * 1024 ** 3 }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function byId(checks: EnvironmentCheck[], id: string): EnvironmentCheck {
|
||||
const found = checks.find(c => c.id === id);
|
||||
if (!found) throw new Error(`missing check: ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
// remediation only exists on warn / fail rows (discriminated union); this reads
|
||||
// it without forcing a narrow at every assertion site.
|
||||
function remediationOf(c: EnvironmentCheck): string | undefined {
|
||||
return 'remediation' in c ? c.remediation : undefined;
|
||||
}
|
||||
|
||||
describe('collectEnvironmentReport', () => {
|
||||
it('passes every check on a healthy environment', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes());
|
||||
expect(checks.map(c => c.id)).toEqual([
|
||||
'docker_socket', 'docker_compose', 'compose_dir', 'path_mapping', 'tls', 'disk_space',
|
||||
]);
|
||||
expect(checks.every(c => c.status === 'pass')).toBe(true);
|
||||
});
|
||||
|
||||
it('every non-pass row carries remediation', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
pingDocker: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); },
|
||||
composeVersion: async () => { throw new Error('not found'); },
|
||||
accessDir: async () => ({ exists: false, isDir: false, writable: false }),
|
||||
bindMounts: async () => [{ source: '/host/compose', destination: '/app/compose' }],
|
||||
proto: 'http',
|
||||
diskUsage: async () => ({ usePercent: 96, freeBytes: 1 * 1024 ** 3 }),
|
||||
}));
|
||||
for (const c of checks) {
|
||||
if (c.status !== 'pass') expect(c.remediation, `${c.id} needs remediation`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
describe('docker_socket', () => {
|
||||
it('flags a permission error distinctly', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
pingDocker: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); },
|
||||
}));
|
||||
const c = byId(checks, 'docker_socket');
|
||||
expect(c.status).toBe('fail');
|
||||
// The permission remediation is the only one that mentions the docker group.
|
||||
expect(remediationOf(c)).toMatch(/docker group/i);
|
||||
});
|
||||
|
||||
it('flags an unreachable daemon with different guidance', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
pingDocker: async () => { throw Object.assign(new Error('down'), { code: 'ENOENT' }); },
|
||||
}));
|
||||
const c = byId(checks, 'docker_socket');
|
||||
expect(c.status).toBe('fail');
|
||||
expect(remediationOf(c)).toMatch(/running/i);
|
||||
// Must not give the permission-fix advice for a daemon-down error.
|
||||
expect(remediationOf(c)).not.toMatch(/docker group/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('docker_compose', () => {
|
||||
it('fails when the plugin is absent', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
composeVersion: async () => { throw new Error('unknown command "compose"'); },
|
||||
}));
|
||||
const c = byId(checks, 'docker_compose');
|
||||
expect(c.status).toBe('fail');
|
||||
expect(remediationOf(c)).toMatch(/install/i);
|
||||
});
|
||||
|
||||
it('warns (not fails) when the version check times out', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
composeVersion: async () => { throw Object.assign(new Error('timed out'), { killed: true, signal: 'SIGTERM', code: 'ETIMEDOUT' }); },
|
||||
}));
|
||||
const c = byId(checks, 'docker_compose');
|
||||
expect(c.status).toBe('warn');
|
||||
// A timeout must not tell the operator to install an already-present plugin.
|
||||
expect(remediationOf(c)).not.toMatch(/install/i);
|
||||
});
|
||||
|
||||
it('reports the version string on success', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({ composeVersion: async () => 'v2.31.0' }));
|
||||
const c = byId(checks, 'docker_compose');
|
||||
expect(c.status).toBe('pass');
|
||||
expect(c.detail).toContain('v2.31.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compose_dir', () => {
|
||||
it('fails when the directory is missing', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
accessDir: async () => ({ exists: false, isDir: false, writable: false }),
|
||||
}));
|
||||
expect(byId(checks, 'compose_dir').status).toBe('fail');
|
||||
});
|
||||
|
||||
it('fails when the directory is not writable', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
accessDir: async () => ({ exists: true, isDir: true, writable: false }),
|
||||
}));
|
||||
const c = byId(checks, 'compose_dir');
|
||||
expect(c.status).toBe('fail');
|
||||
expect(c.detail).toMatch(/not writable/i);
|
||||
});
|
||||
|
||||
it('fails when the path exists but is not a directory', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
accessDir: async () => ({ exists: true, isDir: false, writable: false }),
|
||||
}));
|
||||
const c = byId(checks, 'compose_dir');
|
||||
expect(c.status).toBe('fail');
|
||||
expect(c.detail).toMatch(/not a directory/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path_mapping', () => {
|
||||
it('passes when not containerized', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({ bindMounts: async () => null }));
|
||||
expect(byId(checks, 'path_mapping').status).toBe('pass');
|
||||
});
|
||||
|
||||
it('warns (not a false pass) when containerized but mounts are unreadable', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
bindMounts: async () => { throw new Error('self-inspect unavailable'); },
|
||||
}));
|
||||
const c = byId(checks, 'path_mapping');
|
||||
expect(c.status).toBe('warn');
|
||||
expect(c.detail).toMatch(/could not read/i);
|
||||
});
|
||||
|
||||
it('warns when host and container paths differ', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
bindMounts: async () => [{ source: '/srv/host-compose', destination: '/app/compose' }],
|
||||
}));
|
||||
const c = byId(checks, 'path_mapping');
|
||||
expect(c.status).toBe('warn');
|
||||
expect(remediationOf(c)).toContain('/app/compose:/app/compose');
|
||||
});
|
||||
|
||||
it('warns when the compose dir is not under a bind mount', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
bindMounts: async () => [{ source: '/srv/other', destination: '/data' }],
|
||||
}));
|
||||
expect(byId(checks, 'path_mapping').status).toBe('warn');
|
||||
});
|
||||
|
||||
it('treats a trailing slash as equal', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
bindMounts: async () => [{ source: '/app/compose/', destination: '/app/compose' }],
|
||||
}));
|
||||
expect(byId(checks, 'path_mapping').status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes a 1:1 parent bind that covers the compose dir', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
composeDir: '/opt/compose',
|
||||
bindMounts: async () => [{ source: '/opt', destination: '/opt' }],
|
||||
}));
|
||||
expect(byId(checks, 'path_mapping').status).toBe('pass');
|
||||
});
|
||||
|
||||
it('warns when a parent bind maps the compose dir to a different host path', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
composeDir: '/opt/compose',
|
||||
bindMounts: async () => [{ source: '/srv/opt', destination: '/opt' }],
|
||||
}));
|
||||
const c = byId(checks, 'path_mapping');
|
||||
expect(c.status).toBe('warn');
|
||||
expect(c.detail).toContain('/srv/opt/compose');
|
||||
});
|
||||
|
||||
it('selects the longest-prefix mount when both a parent and child bind exist', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
composeDir: '/opt/compose',
|
||||
bindMounts: async () => [
|
||||
{ source: '/wrong', destination: '/opt' },
|
||||
{ source: '/opt/compose', destination: '/opt/compose' },
|
||||
],
|
||||
}));
|
||||
expect(byId(checks, 'path_mapping').status).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tls', () => {
|
||||
it('warns on plain HTTP to a non-loopback host', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'http', host: 'sencho.example.com' }));
|
||||
expect(byId(checks, 'tls').status).toBe('warn');
|
||||
});
|
||||
|
||||
it('passes on HTTP to localhost', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'http', host: 'localhost:1852' }));
|
||||
expect(byId(checks, 'tls').status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes on HTTP to an IPv6 loopback literal', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'http', host: '[::1]:1852' }));
|
||||
expect(byId(checks, 'tls').status).toBe('pass');
|
||||
});
|
||||
|
||||
it('passes on HTTPS', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'https', host: 'sencho.example.com' }));
|
||||
expect(byId(checks, 'tls').status).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disk_space', () => {
|
||||
it('warns on high usage', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
diskUsage: async () => ({ usePercent: 95, freeBytes: 20 * 1024 ** 3 }),
|
||||
}));
|
||||
expect(byId(checks, 'disk_space').status).toBe('warn');
|
||||
});
|
||||
|
||||
it('warns on low free space even when usage percent is moderate', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
diskUsage: async () => ({ usePercent: 70, freeBytes: 1 * 1024 ** 3 }),
|
||||
}));
|
||||
expect(byId(checks, 'disk_space').status).toBe('warn');
|
||||
});
|
||||
|
||||
it('warns at exactly the usage threshold', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
diskUsage: async () => ({ usePercent: 90, freeBytes: 50 * 1024 ** 3 }),
|
||||
}));
|
||||
expect(byId(checks, 'disk_space').status).toBe('warn');
|
||||
});
|
||||
|
||||
it('warns at exactly the free-space threshold', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
diskUsage: async () => ({ usePercent: 50, freeBytes: 2 * 1024 ** 3 - 1 }),
|
||||
}));
|
||||
expect(byId(checks, 'disk_space').status).toBe('warn');
|
||||
});
|
||||
|
||||
it('warns (not a false pass) when usage cannot be determined', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({ diskUsage: async () => null }));
|
||||
const c = byId(checks, 'disk_space');
|
||||
expect(c.status).toBe('warn');
|
||||
expect(remediationOf(c)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickBackingMount', () => {
|
||||
it('returns null for an empty list', () => {
|
||||
expect(pickBackingMount([], '/app/compose')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the longest matching prefix mount', () => {
|
||||
const got = pickBackingMount([
|
||||
{ mount: '/', use: 50, available: 100 },
|
||||
{ mount: '/app', use: 60, available: 200 },
|
||||
{ mount: '/app/compose', use: 70, available: 300 },
|
||||
], '/app/compose');
|
||||
expect(got).toEqual({ usePercent: 70, freeBytes: 300 });
|
||||
});
|
||||
|
||||
it('does not treat /app as a prefix of /application', () => {
|
||||
const got = pickBackingMount([
|
||||
{ mount: '/', use: 10, available: 999 },
|
||||
{ mount: '/app', use: 80, available: 5 },
|
||||
], '/application/data');
|
||||
// /app is not a path-segment prefix of /application, so root wins.
|
||||
expect(got).toEqual({ usePercent: 10, freeBytes: 999 });
|
||||
});
|
||||
|
||||
it('falls back to the C: mount on Windows when nothing matches', () => {
|
||||
const got = pickBackingMount([
|
||||
{ mount: 'D:', use: 20, available: 10 },
|
||||
{ mount: 'C:', use: 30, available: 40 },
|
||||
], 'E:\\compose');
|
||||
expect(got).toEqual({ usePercent: 30, freeBytes: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades to a non-throwing report when probes reject', async () => {
|
||||
const { checks } = await collectEnvironmentReport(baseProbes({
|
||||
accessDir: async () => { throw new Error('stat blew up'); },
|
||||
bindMounts: async () => { throw new Error('inspect blew up'); },
|
||||
diskUsage: async () => { throw new Error('fsSize blew up'); },
|
||||
}));
|
||||
// accessDir rejection degrades to "missing" -> fail; a rejected bindMounts
|
||||
// is unverifiable -> warn; a rejected diskUsage is unknown -> warn.
|
||||
expect(byId(checks, 'compose_dir').status).toBe('fail');
|
||||
expect(byId(checks, 'path_mapping').status).toBe('warn');
|
||||
expect(byId(checks, 'disk_space').status).toBe('warn');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { requireAdmin, requireUserSession } from '../middleware/tierGates';
|
||||
import { collectDiagnostics } from '../services/DiagnosticsService';
|
||||
import { collectEnvironmentReport, buildRealProbes } from '../services/EnvironmentCheckService';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { withTimeout } from '../utils/withTimeout';
|
||||
|
||||
@@ -36,3 +37,22 @@ diagnosticsRouter.get('/', async (req: Request, res: Response): Promise<void> =>
|
||||
res.status(500).json({ error: 'Failed to collect diagnostics.' });
|
||||
}
|
||||
});
|
||||
|
||||
// First-run / preflight environment checks (Docker engine + Compose, the
|
||||
// compose directory and its host path mapping, TLS, disk headroom). Same admin
|
||||
// session gate as the recovery report. proto / host come from the request so
|
||||
// the TLS verdict reflects how this browser reached the dashboard; behind a
|
||||
// reverse proxy that terminates TLS, x-forwarded-proto carries the real scheme.
|
||||
diagnosticsRouter.get('/environment', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireUserSession(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const proto = (req.get('x-forwarded-proto')?.split(',')[0].trim()) || req.protocol;
|
||||
const host = req.get('host') || '';
|
||||
const report = await collectEnvironmentReport(buildRealProbes({ proto, host }));
|
||||
res.json(report);
|
||||
} catch (err) {
|
||||
console.error('[diagnostics] failed to collect environment report:', (err as Error).message);
|
||||
res.status(500).json({ error: 'Failed to collect environment checks.' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* First-run / preflight environment checks shared by the setup wizard's final
|
||||
* step and the admin Recovery settings tab. Where DiagnosticsService answers
|
||||
* "is my install broken" (and runs without Docker), this answers "can my
|
||||
* install actually run Docker deploys": is the Docker socket reachable and
|
||||
* permitted, is `docker compose` v2 present, is the compose directory writable
|
||||
* and mounted at the same path on host and container, is the dashboard behind
|
||||
* TLS, and is there disk headroom.
|
||||
*
|
||||
* The mapping from raw probe results to check rows is kept pure and the IO is
|
||||
* injected (see `EnvironmentProbes` / `buildRealProbes`), so the verdict logic
|
||||
* is unit-testable without a live Docker daemon or filesystem. No probe failure
|
||||
* throws out of the report: it degrades to a non-throwing row whose status fits
|
||||
* the check. The Docker socket and compose-directory probes surface a `fail`;
|
||||
* an unreadable disk degrades to a `warn` rather than a false `pass`. Path
|
||||
* mapping distinguishes "not containerized" (a `pass`, the dev / bare-metal
|
||||
* case) from "containerized but mounts unreadable" (a `warn`), so an
|
||||
* unverifiable mapping never reads as healthy.
|
||||
*/
|
||||
import fs from 'fs/promises';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import si from 'systeminformation';
|
||||
import DockerController from './DockerController';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { withTimeout } from '../utils/withTimeout';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DOCKER_PING_TIMEOUT_MS = 2000;
|
||||
const COMPOSE_VERSION_TIMEOUT_MS = 5000;
|
||||
const DISK_WARN_USE_PERCENT = 90;
|
||||
const DISK_WARN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
|
||||
|
||||
export type CheckStatus = 'pass' | 'warn' | 'fail';
|
||||
|
||||
export type CheckId =
|
||||
| 'docker_socket'
|
||||
| 'docker_compose'
|
||||
| 'compose_dir'
|
||||
| 'path_mapping'
|
||||
| 'tls'
|
||||
| 'disk_space';
|
||||
|
||||
interface CheckBase {
|
||||
id: CheckId;
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
// A pass row carries no remediation; a warn / fail row must carry one. Modelling
|
||||
// this as a discriminated union makes "every actionable verdict ships a fix" a
|
||||
// compile-time guarantee instead of a convention a future check could forget.
|
||||
export type EnvironmentCheck =
|
||||
| (CheckBase & { status: 'pass' })
|
||||
| (CheckBase & { status: 'warn' | 'fail'; remediation: string });
|
||||
|
||||
export interface EnvironmentReport {
|
||||
checks: EnvironmentCheck[];
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
export interface DirAccess {
|
||||
exists: boolean;
|
||||
isDir: boolean;
|
||||
writable: boolean;
|
||||
}
|
||||
|
||||
export interface DiskUsage {
|
||||
usePercent: number;
|
||||
freeBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injected IO for the checks. The route wires `buildRealProbes`; tests pass
|
||||
* stubs. `proto` / `host` come from the request so the TLS check reflects how
|
||||
* the operator's browser actually reached the dashboard.
|
||||
*/
|
||||
export interface EnvironmentProbes {
|
||||
proto: string;
|
||||
host: string;
|
||||
composeDir: string;
|
||||
/** Resolves when the Docker daemon answers a ping; rejects (with `.code`) otherwise. */
|
||||
pingDocker: () => Promise<void>;
|
||||
/** Resolves the `docker compose` version string; rejects when the plugin is absent. */
|
||||
composeVersion: () => Promise<string>;
|
||||
accessDir: (dir: string) => Promise<DirAccess>;
|
||||
/**
|
||||
* Bind mounts on the Sencho container, or null when not containerized. A
|
||||
* rejection means containerized-but-unreadable and is reported as an
|
||||
* unverified path-mapping warn rather than a false pass.
|
||||
*/
|
||||
bindMounts: () => Promise<Array<{ source: string; destination: string }> | null>;
|
||||
/** Disk usage of the filesystem backing the compose dir, or null when unknown. */
|
||||
diskUsage: (dir: string) => Promise<DiskUsage | null>;
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
// Strip the port; IPv6 literals arrive bracketed (`[::1]:1852`).
|
||||
const name = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
|
||||
return name === 'localhost' || name === '127.0.0.1' || name === '::1' || name === '';
|
||||
}
|
||||
|
||||
// Normalize for path comparison: drop a single trailing slash so
|
||||
// `/app/compose` and `/app/compose/` compare equal.
|
||||
function normPath(p: string): string {
|
||||
return p.length > 1 ? p.replace(/[/\\]+$/, '') : p;
|
||||
}
|
||||
|
||||
async function checkDockerSocket(probe: EnvironmentProbes['pingDocker']): Promise<EnvironmentCheck> {
|
||||
const base = { id: 'docker_socket' as const, label: 'Docker engine' };
|
||||
try {
|
||||
await probe();
|
||||
return { ...base, status: 'pass', detail: 'The Docker daemon is reachable.' };
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code;
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return {
|
||||
...base,
|
||||
status: 'fail',
|
||||
detail: 'Permission denied talking to the Docker socket.',
|
||||
remediation:
|
||||
'Sencho cannot read /var/run/docker.sock. Mount the socket into the container '
|
||||
+ '(-v /var/run/docker.sock:/var/run/docker.sock) and make sure Sencho runs as a '
|
||||
+ 'user in the docker group, or with access to the socket.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
status: 'fail',
|
||||
detail: 'The Docker daemon is not reachable.',
|
||||
remediation:
|
||||
'Confirm Docker is running on the host and that /var/run/docker.sock is mounted into '
|
||||
+ 'the Sencho container (-v /var/run/docker.sock:/var/run/docker.sock).',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDockerCompose(probe: EnvironmentProbes['composeVersion']): Promise<EnvironmentCheck> {
|
||||
const base = { id: 'docker_compose' as const, label: 'Docker Compose' };
|
||||
try {
|
||||
const version = (await probe()).trim();
|
||||
return { ...base, status: 'pass', detail: version ? `Compose ${version} is available.` : 'Compose is available.' };
|
||||
} catch (err) {
|
||||
// A timeout (slow / busy daemon) is not the same as an absent plugin, so
|
||||
// it must not produce the confident "install the plugin" remediation,
|
||||
// which would send the operator down the wrong path.
|
||||
const e = err as { code?: string; killed?: boolean; signal?: string | null };
|
||||
if (e.killed || e.signal != null || e.code === 'ETIMEDOUT') {
|
||||
return {
|
||||
...base,
|
||||
status: 'warn',
|
||||
detail: 'Could not verify the Compose version in time.',
|
||||
remediation:
|
||||
'The `docker compose version` check timed out, usually a slow or busy Docker daemon. '
|
||||
+ 'Re-run once the host settles; if it keeps timing out, check daemon health.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
status: 'fail',
|
||||
detail: 'The Docker Compose v2 plugin was not found.',
|
||||
remediation:
|
||||
'Install the Docker Compose v2 plugin so `docker compose version` succeeds. The official '
|
||||
+ 'Docker images bundle it; on a bare host, install the docker-compose-plugin package.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkComposeDir(dir: string, access: DirAccess): EnvironmentCheck {
|
||||
const base = { id: 'compose_dir' as const, label: 'Compose directory' };
|
||||
if (!access.exists) {
|
||||
return {
|
||||
...base,
|
||||
status: 'fail',
|
||||
detail: `${dir} does not exist.`,
|
||||
remediation: `Create ${dir} on the host and mount it into Sencho, or point COMPOSE_DIR at an existing mounted directory.`,
|
||||
};
|
||||
}
|
||||
if (!access.isDir) {
|
||||
return {
|
||||
...base,
|
||||
status: 'fail',
|
||||
detail: `${dir} exists but is not a directory.`,
|
||||
remediation: `Remove the file at ${dir} or point COMPOSE_DIR at a directory.`,
|
||||
};
|
||||
}
|
||||
if (!access.writable) {
|
||||
return {
|
||||
...base,
|
||||
status: 'fail',
|
||||
detail: `${dir} is not writable.`,
|
||||
remediation: `Grant the user Sencho runs as write access to ${dir} so it can author and update compose files.`,
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'pass', detail: `${dir} is present and writable.` };
|
||||
}
|
||||
|
||||
// Bind mounts on the Sencho container: an array when containerized, `null` when
|
||||
// confirmed not containerized, `'unknown'` when containerized but the mounts
|
||||
// could not be read (so the verdict is an unverified warn, not a false pass).
|
||||
type BindMounts = Array<{ source: string; destination: string }> | null | 'unknown';
|
||||
|
||||
function checkPathMapping(dir: string, mounts: BindMounts): EnvironmentCheck {
|
||||
const base = { id: 'path_mapping' as const, label: 'Path mapping' };
|
||||
if (mounts === 'unknown') {
|
||||
return {
|
||||
...base,
|
||||
status: 'warn',
|
||||
detail: 'Could not read the container mounts to verify path mapping.',
|
||||
remediation:
|
||||
`Sencho is running in a container but could not inspect its own mounts. Confirm the compose `
|
||||
+ `directory is bind-mounted from the host at the same path, e.g. -v ${dir}:${dir}.`,
|
||||
};
|
||||
}
|
||||
if (mounts === null) {
|
||||
return {
|
||||
...base,
|
||||
status: 'pass',
|
||||
detail: 'Sencho is not running in a container; host and container paths are the same.',
|
||||
};
|
||||
}
|
||||
const target = normPath(dir);
|
||||
// The bind mount covering the compose dir is the one whose destination is the
|
||||
// longest path prefix of it, so a parent bind (-v /opt:/opt) covers
|
||||
// COMPOSE_DIR=/opt/compose just as a direct -v /opt/compose:/opt/compose does.
|
||||
const match = mounts
|
||||
.filter(m => {
|
||||
const d = normPath(m.destination);
|
||||
return target === d || target.startsWith(d + '/') || target.startsWith(d + '\\');
|
||||
})
|
||||
.sort((a, b) => normPath(b.destination).length - normPath(a.destination).length)[0];
|
||||
if (!match) {
|
||||
return {
|
||||
...base,
|
||||
status: 'warn',
|
||||
detail: `${dir} is not under a host bind mount.`,
|
||||
remediation:
|
||||
`Bind-mount the compose directory from the host, e.g. -v ${dir}:${dir}. Without it, relative `
|
||||
+ `bind mounts in your stacks resolve against the container filesystem instead of the host.`,
|
||||
};
|
||||
}
|
||||
// The host path the daemon resolves for the compose dir: the mount source
|
||||
// plus the compose dir's path below the mount destination.
|
||||
const relative = target.slice(normPath(match.destination).length);
|
||||
const hostPath = normPath(normPath(match.source) + relative);
|
||||
if (hostPath !== target) {
|
||||
return {
|
||||
...base,
|
||||
status: 'warn',
|
||||
detail: `Host path ${hostPath} is mounted at ${dir}.`,
|
||||
remediation:
|
||||
`Mount the compose directory at the same path on host and container, e.g. -v ${dir}:${dir}. `
|
||||
+ `A mismatch breaks relative bind mounts in your stacks, because the daemon resolves them `
|
||||
+ `against the host path Sencho never sees.`,
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'pass', detail: `Mounted 1:1 at ${dir}.` };
|
||||
}
|
||||
|
||||
function checkTls(proto: string, host: string): EnvironmentCheck {
|
||||
const base = { id: 'tls' as const, label: 'TLS' };
|
||||
if (proto === 'https' || isLoopbackHost(host)) {
|
||||
return { ...base, status: 'pass', detail: proto === 'https' ? 'Reached over HTTPS.' : 'Reached over a loopback address.' };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
status: 'warn',
|
||||
detail: `Reached over plain HTTP at ${host}.`,
|
||||
remediation:
|
||||
'Put Sencho behind a reverse proxy that terminates TLS (Caddy, Traefik, nginx) before exposing '
|
||||
+ 'it beyond localhost. Credentials and session cookies travel in clear text over plain HTTP.',
|
||||
};
|
||||
}
|
||||
|
||||
function checkDisk(dir: string, usage: DiskUsage | null): EnvironmentCheck {
|
||||
const base = { id: 'disk_space' as const, label: 'Disk space' };
|
||||
if (!usage) {
|
||||
// Unknown is not healthy: on a preflight surface a green check would
|
||||
// tell the operator there is headroom that was never measured.
|
||||
return {
|
||||
...base,
|
||||
status: 'warn',
|
||||
detail: 'Disk usage could not be determined.',
|
||||
remediation:
|
||||
'Sencho could not read host filesystem stats. An unknown disk state is not a guarantee of '
|
||||
+ 'free space, so confirm the compose volume has headroom before deploying.',
|
||||
};
|
||||
}
|
||||
const freeGiB = (usage.freeBytes / (1024 * 1024 * 1024)).toFixed(1);
|
||||
if (usage.usePercent >= DISK_WARN_USE_PERCENT || usage.freeBytes < DISK_WARN_FREE_BYTES) {
|
||||
return {
|
||||
...base,
|
||||
status: 'warn',
|
||||
detail: `${usage.usePercent.toFixed(0)}% used, ${freeGiB} GiB free on the compose volume.`,
|
||||
remediation:
|
||||
'Free up disk space before deploying. Pull and build steps fail partway through on a full '
|
||||
+ 'volume; prune unused images and volumes from Settings or the host.',
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'pass', detail: `${usage.usePercent.toFixed(0)}% used, ${freeGiB} GiB free.` };
|
||||
}
|
||||
|
||||
// Each probe is awaited inside its check builder's own try/catch (or returns a
|
||||
// degraded row), so one failing probe never rejects the whole report.
|
||||
export async function collectEnvironmentReport(probes: EnvironmentProbes): Promise<EnvironmentReport> {
|
||||
const logProbeFailure = (label: string) => (e: unknown) => {
|
||||
console.warn(`[env-check] ${label} probe failed: ${(e as Error)?.message ?? String(e)}`);
|
||||
};
|
||||
const [socket, compose, access, mounts, disk] = await Promise.all([
|
||||
checkDockerSocket(probes.pingDocker),
|
||||
checkDockerCompose(probes.composeVersion),
|
||||
probes.accessDir(probes.composeDir).then(
|
||||
a => a,
|
||||
(e): DirAccess => { logProbeFailure('accessDir')(e); return { exists: false, isDir: false, writable: false }; },
|
||||
),
|
||||
probes.bindMounts().then(
|
||||
(m): BindMounts => m,
|
||||
(e): BindMounts => { logProbeFailure('bindMounts')(e); return 'unknown'; },
|
||||
),
|
||||
probes.diskUsage(probes.composeDir).then(d => d, (e) => { logProbeFailure('diskUsage')(e); return null; }),
|
||||
]);
|
||||
|
||||
const checks: EnvironmentCheck[] = [
|
||||
socket,
|
||||
compose,
|
||||
checkComposeDir(probes.composeDir, access),
|
||||
checkPathMapping(probes.composeDir, mounts),
|
||||
checkTls(probes.proto, probes.host),
|
||||
checkDisk(probes.composeDir, disk),
|
||||
];
|
||||
|
||||
return { checks, generatedAt: Date.now() };
|
||||
}
|
||||
|
||||
async function realAccessDir(dir: string): Promise<DirAccess> {
|
||||
try {
|
||||
const stat = await fs.stat(dir);
|
||||
if (!stat.isDirectory()) return { exists: true, isDir: false, writable: false };
|
||||
try {
|
||||
await fs.access(dir, fsConstants.W_OK);
|
||||
return { exists: true, isDir: true, writable: true };
|
||||
} catch {
|
||||
return { exists: true, isDir: true, writable: false };
|
||||
}
|
||||
} catch {
|
||||
return { exists: false, isDir: false, writable: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the filesystem backing `dir` from a `systeminformation` fsSize list:
|
||||
* the mount that is the longest path prefix of `dir` (so a dedicated compose
|
||||
* volume wins over the root mount), then `/` or `C:`, then the first entry.
|
||||
* Exported for unit testing the selection without a live filesystem.
|
||||
*/
|
||||
export function pickBackingMount(
|
||||
sizes: Array<{ mount: string; use?: number; available?: number }>,
|
||||
dir: string,
|
||||
): DiskUsage | null {
|
||||
if (sizes.length === 0) return null;
|
||||
const target = normPath(dir);
|
||||
const backing = sizes
|
||||
.filter(s => {
|
||||
const m = normPath(s.mount);
|
||||
return target === m || target.startsWith(m + '/') || target.startsWith(m + '\\');
|
||||
})
|
||||
.sort((a, b) => b.mount.length - a.mount.length)[0]
|
||||
?? sizes.find(s => s.mount === '/' || s.mount === 'C:')
|
||||
?? sizes[0];
|
||||
if (!backing) return null;
|
||||
return { usePercent: backing.use ?? 0, freeBytes: backing.available ?? 0 };
|
||||
}
|
||||
|
||||
async function realDiskUsage(dir: string): Promise<DiskUsage | null> {
|
||||
return pickBackingMount(await si.fsSize(), dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the real IO for the checks. Called by the diagnostics route; tests build
|
||||
* `EnvironmentProbes` directly with stubs.
|
||||
*/
|
||||
export function buildRealProbes(opts: { proto: string; host: string }): EnvironmentProbes {
|
||||
const composeDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId());
|
||||
return {
|
||||
proto: opts.proto,
|
||||
host: opts.host,
|
||||
composeDir,
|
||||
pingDocker: async () => {
|
||||
await withTimeout(DockerController.getInstance().getDocker().ping(), DOCKER_PING_TIMEOUT_MS, 'docker-ping');
|
||||
},
|
||||
composeVersion: async () => {
|
||||
const { stdout } = await execFileAsync('docker', ['compose', 'version', '--short'], { timeout: COMPOSE_VERSION_TIMEOUT_MS });
|
||||
return stdout;
|
||||
},
|
||||
accessDir: realAccessDir,
|
||||
bindMounts: () => SelfIdentityService.getInstance().getBindMounts(),
|
||||
diskUsage: realDiskUsage,
|
||||
};
|
||||
}
|
||||
@@ -154,6 +154,32 @@ class SelfIdentityService {
|
||||
return this.volumeNames.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind mounts on the running Sencho container, used by the environment
|
||||
* checker to verify the compose directory is mounted at the same path on the
|
||||
* host and inside the container. Returns null when Sencho is not running in
|
||||
* Docker (dev / bare metal), where the 1:1 path-mapping concern does not
|
||||
* apply. Throws when Sencho IS containerized (a container id was resolved at
|
||||
* startup) but its own mounts cannot be read now, so the caller can report an
|
||||
* unverified state instead of a false "not containerized". Re-inspects on
|
||||
* each call rather than caching, because it runs only on an admin-triggered
|
||||
* diagnostic.
|
||||
*/
|
||||
async getBindMounts(): Promise<Array<{ source: string; destination: string }> | null> {
|
||||
const docker = DockerController.getInstance().getDocker();
|
||||
const info = await this.resolveSelfInspect(docker);
|
||||
if (!info) {
|
||||
if (this.containerId) {
|
||||
throw new Error('container self-inspect unavailable; cannot read mounts');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const mounts = (info.Mounts ?? []) as Array<{ Type?: string; Source?: string; Destination?: string }>;
|
||||
return mounts
|
||||
.filter(m => m.Type === 'bind' && m.Source && m.Destination)
|
||||
.map(m => ({ source: m.Source as string, destination: m.Destination as string }));
|
||||
}
|
||||
|
||||
/** Diagnostic snapshot used by route handlers when composing error responses. */
|
||||
getIdentity(): {
|
||||
containerId: string | null;
|
||||
|
||||
@@ -83,6 +83,8 @@ Open `http://localhost:1852` in a browser. On a fresh install you land on the **
|
||||
|
||||
Pick a username (the placeholder shows `admin`), choose a password, confirm it, and click **Initialize console**. The strength indicator under the password field expects at least eight characters.
|
||||
|
||||
Sencho then runs a short **environment preflight**: it confirms the Docker engine and Compose plugin are reachable, the compose directory is writable and mounted at a matching host path, the dashboard is behind TLS, and there is disk headroom. Anything that needs attention shows an inline fix. The checks never block you, so click **Enter Sencho** to continue; you can re-run them anytime from **Settings · Recovery**.
|
||||
|
||||
<Note>
|
||||
The Cold start card only appears the first time you open Sencho. Once the admin account exists, every subsequent visit goes to the regular sign-in screen.
|
||||
</Note>
|
||||
|
||||
@@ -18,6 +18,20 @@ Your compose files on disk are always the source of truth. Sencho reads them; it
|
||||
|
||||
---
|
||||
|
||||
## Environment checks
|
||||
|
||||
Sencho runs a short environment preflight as the final step of first-boot setup, and you can re-run it anytime from **Settings · Recovery**. It confirms the things a deploy depends on, so a deploy does not fail for an avoidable reason:
|
||||
|
||||
- the Docker engine is reachable and Sencho has permission to talk to the socket,
|
||||
- the Docker Compose plugin is present,
|
||||
- the compose directory exists, is writable, and is mounted at a matching host path (the [1:1 path rule](/getting-started/configuration#compose-directory-the-11-path-rule)),
|
||||
- the dashboard is reached over TLS rather than plain HTTP,
|
||||
- and the compose volume has disk headroom.
|
||||
|
||||
Every result that needs attention carries a specific fix rather than a generic error. The checks are advisory: they never stop you from continuing, so an operator who knows their setup can move straight on.
|
||||
|
||||
---
|
||||
|
||||
## Sencho itself won't start or is misbehaving
|
||||
|
||||
**Symptom:** The dashboard won't load, the container restarts in a loop, or the UI is throwing errors with no useful message.
|
||||
|
||||
+5
-1
@@ -69,7 +69,11 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T
|
||||
const confirmInput = page.locator('#confirmPassword');
|
||||
if (await confirmInput.isVisible()) await confirmInput.fill(password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
// After setup, the app logs in automatically and shows the dashboard
|
||||
// Setup signs the new admin in and then shows an environment-preflight
|
||||
// step; clicking "Enter Sencho" completes onboarding and lands the dashboard.
|
||||
const enterButton = page.getByRole('button', { name: /enter sencho/i });
|
||||
await expect(enterButton).toBeVisible({ timeout: 10_000 });
|
||||
await enterButton.click();
|
||||
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { AuthCanvas } from '@/components/auth/AuthCanvas';
|
||||
import { AuthStepHeader } from '@/components/auth/AuthStepHeader';
|
||||
import { ErrorRail } from '@/components/auth/ErrorRail';
|
||||
import { EnvironmentChecks } from '@/components/settings/EnvironmentChecks';
|
||||
|
||||
interface SetupProps {
|
||||
onComplete: () => void;
|
||||
@@ -34,6 +35,10 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// The admin account is created in step 1; /api/auth/setup signs the operator
|
||||
// in (session cookie), so step 2 can run the admin-gated environment checks
|
||||
// before handing off to the console.
|
||||
const [step, setStep] = useState<'account' | 'env'>('account');
|
||||
|
||||
const strength = gaugePassword(password);
|
||||
const strengthClass =
|
||||
@@ -72,7 +77,7 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.success) {
|
||||
onComplete();
|
||||
setStep('env');
|
||||
} else {
|
||||
setError(data.error || 'Setup failed');
|
||||
}
|
||||
@@ -83,6 +88,37 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co
|
||||
}
|
||||
};
|
||||
|
||||
if (step === 'env') {
|
||||
return (
|
||||
<div className={cn('relative', className)} {...props}>
|
||||
<AuthCanvas
|
||||
footer={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Console · First boot</span>
|
||||
<span className="text-stat-subtitle/70">Account ready</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-7">
|
||||
<AuthStepHeader
|
||||
kicker="SENCHO · ENVIRONMENT"
|
||||
hero="Preflight"
|
||||
caption="A quick check that this host can run Docker deploys. Warnings won't stop you; each one carries a fix."
|
||||
/>
|
||||
<EnvironmentChecks />
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onComplete}
|
||||
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
Enter Sencho<ArrowRight strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</AuthCanvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} {...props}>
|
||||
<AuthCanvas
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { RefreshCw, Check, AlertTriangle, X } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
|
||||
|
||||
// Shape mirrors the backend EnvironmentReport (services/EnvironmentCheckService.ts);
|
||||
// kept local because the frontend cannot import backend types. The panel only
|
||||
// reads checks, so `remediation` stays optional here even though the backend
|
||||
// models it as required on every warn / fail row.
|
||||
type CheckStatus = 'pass' | 'warn' | 'fail';
|
||||
type CheckId = 'docker_socket' | 'docker_compose' | 'compose_dir' | 'path_mapping' | 'tls' | 'disk_space';
|
||||
|
||||
interface EnvironmentCheck {
|
||||
id: CheckId;
|
||||
label: string;
|
||||
status: CheckStatus;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
interface EnvironmentReport {
|
||||
checks: EnvironmentCheck[];
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
const STATUS_WORD: Record<CheckStatus, string> = { pass: 'OK', warn: 'Warning', fail: 'Action needed' };
|
||||
|
||||
function StatusBadge({ status, children }: { status: CheckStatus; children: ReactNode }) {
|
||||
const Icon = status === 'pass' ? Check : status === 'warn' ? AlertTriangle : X;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.18em]',
|
||||
status === 'pass' ? 'text-success' : status === 'warn' ? 'text-warning' : 'text-destructive',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckRow({ check }: { check: EnvironmentCheck }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border px-3 py-2.5',
|
||||
check.status === 'pass'
|
||||
? 'border-card-border bg-card'
|
||||
: check.status === 'warn'
|
||||
? 'border-warning/40 bg-warning/5'
|
||||
: 'border-destructive/40 bg-destructive/5',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">{check.label}</span>
|
||||
<StatusBadge status={check.status}>{STATUS_WORD[check.status]}</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-stat-value">{check.detail}</p>
|
||||
{check.remediation ? (
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-stat-subtitle">{check.remediation}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2" aria-busy="true">
|
||||
{[0, 1, 2, 3, 4, 5].map(i => <Skeleton key={i} className="h-14 w-full" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight environment checks (Docker engine + Compose, the compose directory
|
||||
* and its host path mapping, TLS, disk headroom) with inline remediation.
|
||||
* Layout-neutral so it renders both inside the Recovery settings tab and as the
|
||||
* final step of the setup wizard. Self-contained: fetches on mount and exposes
|
||||
* a Re-run control. It never blocks; the caller decides what continue action,
|
||||
* if any, sits alongside it.
|
||||
*/
|
||||
export function EnvironmentChecks({ className }: { className?: string }) {
|
||||
const [report, setReport] = useState<EnvironmentReport | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/diagnostics/environment', { localOnly: true });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Failed to run environment checks.');
|
||||
setReport(null);
|
||||
return;
|
||||
}
|
||||
setReport(await res.json() as EnvironmentReport);
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Failed to run environment checks.');
|
||||
setReport(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)}>
|
||||
{isLoading ? (
|
||||
<ChecksSkeleton />
|
||||
) : report ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{report.checks.map(check => <CheckRow key={check.id} check={check} />)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-stat-subtitle">Checks could not be run. Try again.</p>
|
||||
)}
|
||||
<SettingsActions hint="environment preflight">
|
||||
<SettingsSecondaryButton onClick={() => void load()} disabled={isLoading}>
|
||||
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
|
||||
Re-run
|
||||
</SettingsSecondaryButton>
|
||||
</SettingsActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SettingsField } from './SettingsField';
|
||||
import { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from './SettingsActions';
|
||||
import { EnvironmentChecks } from './EnvironmentChecks';
|
||||
import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
|
||||
@@ -220,6 +221,16 @@ export function RecoverySection() {
|
||||
</SettingsActions>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Environment"
|
||||
kicker="preflight"
|
||||
description="What deploys depend on: the Docker engine, the Compose plugin, the compose directory and its host path mapping, TLS, and disk headroom. Each warning carries a fix."
|
||||
>
|
||||
<div className="pt-3">
|
||||
<EnvironmentChecks />
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Safe actions"
|
||||
description="Low-risk recovery steps that touch no secrets and no other operators' accounts."
|
||||
|
||||
Reference in New Issue
Block a user