mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
feat: add Compose Doctor preflight checks for stacks (#1348)
* feat: add Compose Doctor preflight checks for stacks Add an on-demand, advisory preflight that renders a stack's effective Compose model with `docker compose config` and runs a registry of deterministic checks before deploy, surfacing findings grouped by severity (blocker, high, warning, info) with a remediation for each. Findings cover unset env vars, host-port conflicts on the node, broad 0.0.0.0 exposure, missing bind-mount paths, a mounted Docker socket, privileged and host networking, moving image tags, missing restart policy and healthcheck, Swarm-only deploy fields, missing external networks or volumes, and container_name collisions. The report is node-scoped and stored as the last run per stack, and the route auto-proxies to the active node so a remote stack is checked on the node that owns it. A new Doctor tab on the stack detail panel runs preflight and shows the grouped findings, with a severity dot on the tab when the last run has blocker or high findings. The tab is gated on a compose-doctor capability so older nodes hide it. No environment value is ever stored, returned, or logged: only env key names and structural facts are read, and render failures surface a generic message or the missing required-variable names, never raw stderr. * fix: scroll the stack tab strip when its tabs overflow Adding the Doctor tab can push the per-stack Anatomy tab strip past the panel width on narrower layouts. Make the tab row scroll horizontally with subtle edge fades that appear only while there is more to scroll in that direction, so a panel wide enough to show every tab is unchanged. * fix: add clickable arrows and wheel scroll to the stack tab strip Hiding the scrollbar left mouse users with no way to scroll the overflowing tab row: a vertical wheel does not move a horizontal overflow and native rows do not drag-scroll. Replace the passive edge fades with clickable chevron arrows shown only when the row overflows that edge, and translate a vertical wheel over the row into horizontal scroll. * fix: inline the path-injection barrier in renderConfig CodeQL's path-injection check does not credit the wrapped isPathWithinBase helper as a sanitizer, so move the containment check inline at the spawn cwd sink, matching the canonical barrier used elsewhere in the codebase. Behavior is unchanged: the resolved stack directory must be contained in the compose base and may not be the base itself. * fix: hoist the compose-config spawn into the path-barrier scope The earlier inline barrier sat in a different scope than the spawn cwd sink (separated by the Promise-executor closure) and used a compound guard, so CodeQL did not credit it. Use the exact canonical startsWith barrier and hoist the spawn into the same scope as the check. Behavior is unchanged: the executor runs synchronously in the same tick as the spawn, so handlers still attach before any event can fire.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Covers the capability-gated Doctor tab and its severity dot in
|
||||
* StackAnatomyPanel when the active node advertises compose-doctor. The
|
||||
* capability-off case (tab hidden, no badge fetch) is covered in
|
||||
* StackAnatomyPanel.test.tsx.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('./stack/StackActivityTimeline', () => ({ StackActivityTimeline: () => <div /> }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: () => true }) }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import StackAnatomyPanel from './StackAnatomyPanel';
|
||||
|
||||
let badgeSeverity: string | null = 'blocker';
|
||||
|
||||
function jsonRes(body: unknown, ok = true) {
|
||||
return { ok, status: ok ? 200 : 404, json: async () => body, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/preflight')) {
|
||||
return jsonRes({ stack: 'web', ranAt: 1, ranBy: 'x', renderable: true, renderError: null, status: 'high', highestSeverity: badgeSeverity, findings: [] });
|
||||
}
|
||||
return jsonRes(null, false); // git-source, update-preview, scan-status
|
||||
});
|
||||
});
|
||||
|
||||
function panel() {
|
||||
return (
|
||||
<StackAnatomyPanel
|
||||
stackName="web"
|
||||
content={'services:\n web:\n image: nginx:1.25\n'}
|
||||
envContent=""
|
||||
selectedEnvFile=".env"
|
||||
gitSourcePending={false}
|
||||
onEditCompose={vi.fn()}
|
||||
onOpenGitSource={vi.fn()}
|
||||
onApplyUpdate={vi.fn()}
|
||||
canEdit
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('StackAnatomyPanel Doctor tab (capability on)', () => {
|
||||
it('renders the Doctor tab with a destructive dot for a blocker result', async () => {
|
||||
badgeSeverity = 'blocker';
|
||||
render(panel());
|
||||
expect(await screen.findByTestId('doctor-tab')).toBeInTheDocument();
|
||||
const dot = await screen.findByTestId('doctor-tab-dot');
|
||||
expect(dot.className).toContain('bg-destructive');
|
||||
});
|
||||
|
||||
it('uses the warning color for a high-risk result', async () => {
|
||||
badgeSeverity = 'high';
|
||||
render(panel());
|
||||
const dot = await screen.findByTestId('doctor-tab-dot');
|
||||
expect(dot.className).toContain('bg-warning');
|
||||
});
|
||||
|
||||
it('shows no dot for a warning-only result', async () => {
|
||||
badgeSeverity = 'warning';
|
||||
render(panel());
|
||||
await screen.findByTestId('doctor-tab');
|
||||
await waitFor(() => expect(vi.mocked(apiFetch).mock.calls.some(([u]) => String(u).includes('/preflight'))).toBe(true));
|
||||
expect(screen.queryByTestId('doctor-tab-dot')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,9 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('./stack/StackActivityTimeline', () => ({
|
||||
StackActivityTimeline: () => <div data-testid="activity-timeline" />,
|
||||
}));
|
||||
// This suite covers the update banner, not the Doctor tab; keep the capability
|
||||
// off so the panel surface stays exactly what these tests assert against.
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: () => false }) }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import StackAnatomyPanel from './StackAnatomyPanel';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
@@ -9,6 +9,8 @@ import { parseAnatomy, parseEnvKeys, formatGitSource, type GitSourceInfo } from
|
||||
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
||||
import StackDossierPanel from './stack/StackDossierPanel';
|
||||
import DriftPanel from './stack/DriftPanel';
|
||||
import PreflightPanel from './stack/PreflightPanel';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
interface StackAnatomyPanelProps {
|
||||
@@ -75,14 +77,71 @@ export default function StackAnatomyPanel({
|
||||
|
||||
const envVarCount = envKeys.size;
|
||||
|
||||
const { hasCapability, activeNode } = useNodes();
|
||||
const doctorEnabled = hasCapability('compose-doctor');
|
||||
|
||||
const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo } | null>(null);
|
||||
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
|
||||
// Last preflight severity, used only to dot the Doctor tab. Radix mounts the
|
||||
// active tab content lazily, so the badge cannot come from PreflightPanel; the
|
||||
// parent reads the stored run once per stack/node change.
|
||||
const [preflightSeverity, setPreflightSeverity] = useState<string | null>(null);
|
||||
const [scanStatus, setScanStatus] = useState<{
|
||||
status: 'ok' | 'partial' | 'failed' | 'skipped' | null;
|
||||
attemptedAt?: number;
|
||||
errorMessage?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Best-effort badge: read the last stored preflight severity to dot the tab.
|
||||
// Skipped when the active node does not advertise the capability.
|
||||
useEffect(() => {
|
||||
// The dot and tab are gated on doctorEnabled, so a stale severity is never
|
||||
// shown; no synchronous reset needed when the capability is absent.
|
||||
if (!doctorEnabled) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/preflight`);
|
||||
if (cancelled || !res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!cancelled) setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
|
||||
} catch {
|
||||
if (!cancelled) setPreflightSeverity(null);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, activeNode?.id, doctorEnabled]);
|
||||
|
||||
// The tab row scrolls horizontally when its tabs overflow the panel width.
|
||||
// Clickable arrows appear only while there is more to scroll in that direction
|
||||
// (a wide panel that fits every tab looks unchanged), and a vertical mouse
|
||||
// wheel over the row is translated into horizontal scroll.
|
||||
const tabScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [tabEdges, setTabEdges] = useState({ left: false, right: false });
|
||||
const measureTabEdges = useCallback((el: HTMLElement) => {
|
||||
setTabEdges({ left: el.scrollLeft > 1, right: Math.ceil(el.scrollLeft + el.clientWidth) < el.scrollWidth });
|
||||
}, []);
|
||||
const scrollTabs = useCallback((direction: -1 | 1) => {
|
||||
const el = tabScrollRef.current;
|
||||
if (el) el.scrollBy({ left: direction * Math.max(96, el.clientWidth * 0.7), behavior: 'smooth' });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const el = tabScrollRef.current;
|
||||
if (!el) return;
|
||||
measureTabEdges(el);
|
||||
// Non-passive so preventDefault works: turn a vertical wheel into horizontal
|
||||
// scroll only when the row overflows (trackpads already scroll horizontally).
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (el.scrollWidth <= el.clientWidth || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||
el.scrollLeft += e.deltaY;
|
||||
e.preventDefault();
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => measureTabEdges(el)) : null;
|
||||
ro?.observe(el);
|
||||
return () => { el.removeEventListener('wheel', onWheel); ro?.disconnect(); };
|
||||
}, [measureTabEdges, doctorEnabled, preflightSeverity]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
@@ -248,13 +307,57 @@ export default function StackAnatomyPanel({
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
|
||||
<Tabs defaultValue="anatomy" className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between border-b border-muted px-3 py-1.5 gap-2">
|
||||
<TabsList className="h-7 gap-0.5 bg-transparent border-none p-0">
|
||||
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div
|
||||
ref={tabScrollRef}
|
||||
onScroll={e => measureTabEdges(e.currentTarget)}
|
||||
className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<TabsList className="h-7 w-max gap-0.5 bg-transparent border-none p-0">
|
||||
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
||||
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
||||
{doctorEnabled && (
|
||||
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Doctor
|
||||
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && (
|
||||
<span
|
||||
data-testid="doctor-tab-dot"
|
||||
className={cn('h-1.5 w-1.5 rounded-full', preflightSeverity === 'blocker' ? 'bg-destructive' : 'bg-warning')}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
{/* Clickable arrows over a fade: shown only when the row overflows that edge. */}
|
||||
{tabEdges.left && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll tabs left"
|
||||
data-testid="tab-scroll-left"
|
||||
onClick={() => scrollTabs(-1)}
|
||||
className="absolute inset-y-0 left-0 flex w-7 items-center justify-start bg-gradient-to-r from-card via-card/90 to-transparent text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
{tabEdges.right && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll tabs right"
|
||||
data-testid="tab-scroll-right"
|
||||
onClick={() => scrollTabs(1)}
|
||||
className="absolute inset-y-0 right-0 flex w-7 items-center justify-end bg-gradient-to-l from-card via-card/90 to-transparent text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{onOpenFiles && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -463,6 +566,11 @@ export default function StackAnatomyPanel({
|
||||
<TabsContent value="drift" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<DriftPanel stackName={stackName} />
|
||||
</TabsContent>
|
||||
{doctorEnabled && (
|
||||
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<PreflightPanel stackName={stackName} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Covers the Compose Doctor panel: the never-run empty state, the all-clear and
|
||||
* graded-findings summaries, the unrenderable banner, a load-failure retry
|
||||
* state, and running preflight on demand.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import PreflightPanel from './PreflightPanel';
|
||||
|
||||
interface Finding {
|
||||
ruleId: string;
|
||||
severity: 'blocker' | 'high' | 'warning' | 'info';
|
||||
title: string;
|
||||
message: string;
|
||||
sourcePath?: string;
|
||||
remediation?: string;
|
||||
service?: string;
|
||||
}
|
||||
interface Report {
|
||||
stack: string;
|
||||
ranAt: number | null;
|
||||
ranBy: string | null;
|
||||
renderable: boolean;
|
||||
renderError: string | null;
|
||||
status: string;
|
||||
highestSeverity: string | null;
|
||||
findings: Finding[];
|
||||
}
|
||||
|
||||
function report(partial: Partial<Report>): Report {
|
||||
return { stack: 'web', ranAt: 1000, ranBy: 'admin', renderable: true, renderError: null, status: 'pass', highestSeverity: null, findings: [], ...partial };
|
||||
}
|
||||
|
||||
function jsonRes(body: unknown, ok = true) {
|
||||
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
describe('PreflightPanel', () => {
|
||||
it('shows the never-run empty state', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'never-run', ranAt: null })));
|
||||
render(<PreflightPanel stackName="web" />);
|
||||
expect(await screen.findByText(/Run preflight to render the effective model/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the all-clear summary when there are no findings', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({ status: 'pass' })));
|
||||
render(<PreflightPanel stackName="web" />);
|
||||
const status = await screen.findByTestId('preflight-status');
|
||||
expect(status).toHaveAttribute('data-status', 'pass');
|
||||
expect(status).toHaveTextContent(/all clear/i);
|
||||
});
|
||||
|
||||
it('groups findings and reflects the highest severity', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'high',
|
||||
highestSeverity: 'high',
|
||||
findings: [
|
||||
{ ruleId: 'privileged', severity: 'high', title: 'Privileged container', message: 'runs privileged', service: 'web' },
|
||||
{ ruleId: 'image-latest', severity: 'warning', title: 'Image uses a moving tag', message: 'latest tag', service: 'web' },
|
||||
],
|
||||
})));
|
||||
render(<PreflightPanel stackName="web" />);
|
||||
const status = await screen.findByTestId('preflight-status');
|
||||
expect(status).toHaveAttribute('data-status', 'high');
|
||||
expect(screen.getByText('Privileged container')).toBeInTheDocument();
|
||||
expect(screen.getByText('Image uses a moving tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces the unrenderable state with the render error', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
renderable: false, status: 'unrenderable', highestSeverity: 'blocker',
|
||||
renderError: 'Sencho could not render the effective Compose model.',
|
||||
findings: [{ ruleId: 'render-failed', severity: 'blocker', title: 'Compose model could not be rendered', message: 'Sencho could not render the effective Compose model.' }],
|
||||
})));
|
||||
render(<PreflightPanel stackName="web" />);
|
||||
const status = await screen.findByTestId('preflight-status');
|
||||
expect(status).toHaveAttribute('data-status', 'unrenderable');
|
||||
expect(status).toHaveTextContent(/cannot render/i);
|
||||
});
|
||||
|
||||
it('shows a retry state and toasts when the load fails', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(null, false));
|
||||
render(<PreflightPanel stackName="web" />);
|
||||
expect(await screen.findByText(/Could not load the preflight report/i)).toBeInTheDocument();
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs preflight on demand and shows the new findings', async () => {
|
||||
vi.mocked(apiFetch)
|
||||
.mockResolvedValueOnce(jsonRes(report({ status: 'never-run', ranAt: null })))
|
||||
.mockResolvedValueOnce(jsonRes(report({
|
||||
status: 'blocker', highestSeverity: 'blocker',
|
||||
findings: [{ ruleId: 'port-conflict-node', severity: 'blocker', title: 'Host port 8080 is already in use', message: 'taken', service: 'web' }],
|
||||
})));
|
||||
render(<PreflightPanel stackName="web" />);
|
||||
fireEvent.click(await screen.findByTestId('preflight-run-btn'));
|
||||
expect(await screen.findByText('Host port 8080 is already in use')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(apiFetch).mock.calls;
|
||||
expect(calls.some(([url, opts]) => String(url).includes('/preflight/run') && (opts as RequestInit | undefined)?.method === 'POST')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
// Mirrors the backend payload shape (the frontend never imports backend).
|
||||
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
|
||||
type PreflightStatus = 'never-run' | 'pass' | 'unrenderable' | PreflightSeverity;
|
||||
|
||||
interface PreflightFinding {
|
||||
ruleId: string;
|
||||
severity: PreflightSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
sourcePath?: string;
|
||||
remediation?: string;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
interface PreflightReport {
|
||||
stack: string;
|
||||
ranAt: number | null;
|
||||
ranBy: string | null;
|
||||
renderable: boolean;
|
||||
renderError: string | null;
|
||||
status: PreflightStatus;
|
||||
highestSeverity: PreflightSeverity | null;
|
||||
findings: PreflightFinding[];
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
const ACTION_CLASS =
|
||||
'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40';
|
||||
const CARD_CLASS = 'rounded-lg border px-3 py-2.5';
|
||||
|
||||
const SEVERITY_META: Record<PreflightSeverity, { label: string; icon: LucideIcon; tone: string }> = {
|
||||
blocker: { label: 'blocker', icon: ShieldAlert, tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive' },
|
||||
high: { label: 'high risk', icon: TriangleAlert, tone: 'border-warning/40 bg-warning/[0.06] text-warning' },
|
||||
warning: { label: 'warning', icon: Info, tone: 'border-info/40 bg-info/[0.06] text-info' },
|
||||
info: { label: 'info', icon: Info, tone: 'border-muted bg-card/40 text-stat-subtitle' },
|
||||
};
|
||||
|
||||
const GROUP_ORDER: PreflightSeverity[] = ['blocker', 'high', 'warning', 'info'];
|
||||
|
||||
/** The header summary card: a single read on the overall result. */
|
||||
function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon; tone: string; line: string } {
|
||||
if (!report.renderable) {
|
||||
return {
|
||||
label: 'cannot render',
|
||||
icon: ShieldAlert,
|
||||
tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive',
|
||||
line: report.renderError ?? 'Sencho could not render the effective Compose model.',
|
||||
};
|
||||
}
|
||||
if (report.findings.length === 0) {
|
||||
return { label: 'all clear', icon: Check, tone: 'border-success/40 bg-success/[0.06] text-success', line: 'No issues found in the effective model.' };
|
||||
}
|
||||
const meta = SEVERITY_META[report.highestSeverity ?? 'info'];
|
||||
const counts = GROUP_ORDER
|
||||
.map(sev => ({ sev, n: report.findings.filter(f => f.severity === sev).length }))
|
||||
.filter(c => c.n > 0)
|
||||
.map(c => `${c.n} ${SEVERITY_META[c.sev].label}`)
|
||||
.join(' · ');
|
||||
return { label: meta.label, icon: meta.icon, tone: meta.tone, line: counts };
|
||||
}
|
||||
|
||||
function FindingRow({ finding }: { finding: PreflightFinding }) {
|
||||
return (
|
||||
<div className="border-t border-muted py-2 first:border-t-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{finding.service && (
|
||||
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-foreground/90">{finding.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-relaxed text-foreground/80">{finding.message}</div>
|
||||
{finding.remediation && (
|
||||
<div className="mt-1 text-[11px] text-stat-subtitle">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wide">fix</span> · {finding.remediation}
|
||||
</div>
|
||||
)}
|
||||
{finding.sourcePath && (
|
||||
<div className="mt-0.5 font-mono text-[10px] text-stat-subtitle">{finding.sourcePath}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const [report, setReport] = useState<PreflightReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
// Passive load of the last stored run when the stack or active node changes.
|
||||
// Read-only: opening the tab never renders or stores anything.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/preflight`);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the preflight report.');
|
||||
return;
|
||||
}
|
||||
setReport((await res.json()) as PreflightReport);
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the preflight report.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId, reloadKey]);
|
||||
|
||||
// Running preflight renders the effective model and stores the result.
|
||||
const runPreflight = async () => {
|
||||
setRunning(true);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/preflight/run`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to run preflight.');
|
||||
return;
|
||||
}
|
||||
setReport((await res.json()) as PreflightReport);
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
toast.error('Failed to run preflight.');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const summary = report && report.status !== 'never-run' ? summaryMeta(report) : null;
|
||||
const SummaryIcon = summary?.icon;
|
||||
const busy = loading || running;
|
||||
|
||||
return (
|
||||
<div data-testid="preflight-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={LABEL_CLASS}>compose doctor</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="preflight-run-btn"
|
||||
onClick={runPreflight}
|
||||
disabled={busy}
|
||||
className={ACTION_CLASS}
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', running && 'animate-spin')} strokeWidth={1.5} /> run preflight
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadError ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
|
||||
<span className="font-mono text-[11px] text-destructive">Could not load the preflight report.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReloadKey(k => k + 1)}
|
||||
className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline"
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
) : !report ? (
|
||||
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Loading preflight…</div>
|
||||
) : report.status === 'never-run' ? (
|
||||
<div className={cn(CARD_CLASS, 'border-muted bg-card/40 flex flex-col items-start gap-2')}>
|
||||
<div className="flex items-center gap-2 text-stat-subtitle">
|
||||
<Stethoscope className="h-4 w-4" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide">no preflight yet</span>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-foreground/80">
|
||||
Run preflight to render the effective model and check this stack for common deploy problems before you apply it.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{summary && SummaryIcon && (
|
||||
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SummaryIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide">{summary.label}</span>
|
||||
{report.ranAt && (
|
||||
<span className="font-mono text-[10px] text-stat-subtitle">
|
||||
· ran {formatTimeAgo(report.ranAt)}{report.ranBy ? ` by ${report.ranBy}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] leading-relaxed text-foreground/80">{summary.line}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{GROUP_ORDER.map(sev => {
|
||||
const items = report.findings.filter(f => f.severity === sev);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section key={sev}>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>{SEVERITY_META[sev].label} · {items.length}</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{items.map((f, i) => <FindingRow key={`${f.ruleId}-${f.service ?? ''}-${i}`} finding={f} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const CAPABILITIES = [
|
||||
'registries',
|
||||
'self-update',
|
||||
'vulnerability-scanning',
|
||||
'compose-doctor',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
Reference in New Issue
Block a user