fix(compose-doctor): resolve effective healthcheck coverage (#1713)

* fix(compose-doctor): resolve effective healthcheck coverage

Compose Doctor now classifies healthcheck coverage from the Compose model, running containers, and local images so image-provided HEALTHCHECKs are not false positives. Update Guard shares the same presence helper so test NONE is not treated as active.

* fix(compose-doctor): fix healthcheck project label and empty compose HC

Use the Compose project name for runtime container listing so stacks whose name: differs from the directory still get runtime evidence. Treat empty or timing-only healthcheck objects as absent rather than active.

* fix(compose-doctor): treat inherited healthcheck as All Clear note

Inherited image healthchecks no longer block All Clear; they surface under a notes section and cannot be acknowledged.
This commit is contained in:
Anso
2026-07-28 14:26:00 -04:00
committed by GitHub
parent c90e9606f1
commit 78475d96ef
27 changed files with 1077 additions and 54 deletions
@@ -26,6 +26,7 @@ import EnvironmentPanel from './stack/EnvironmentPanel';
import ComposeLabelsPanel from './stack/ComposeLabelsPanel';
import StackNetworkingPanel from './stack/StackNetworkingPanel';
import { useNodes } from '@/context/NodeContext';
import { isPreflightNoteFinding } from '@/lib/preflightNotes';
import type { NotificationItem } from '@/components/dashboard/types';
interface StackAnatomyPanelProps {
@@ -184,7 +185,9 @@ export default function StackAnatomyPanel({
if (!cancelled) {
setPreflightSeverity(typeof data?.activeHighestSeverity === 'string' ? data.activeHighestSeverity : null);
const findings = Array.isArray(data?.findings) ? data.findings : undefined;
setPreflightFindings(findings?.filter((f: { acknowledged?: boolean }) => !f.acknowledged));
// Notes do not drive the Doctor tab dismiss fingerprint.
setPreflightFindings(findings?.filter((f: { acknowledged?: boolean; ruleId?: string }) =>
!f.acknowledged && !isPreflightNoteFinding(f.ruleId)));
}
} catch {
if (!cancelled) { setPreflightSeverity(null); setPreflightFindings(undefined); }
@@ -12,6 +12,7 @@ vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { isPreflightNoteFinding } from '@/lib/preflightNotes';
import PreflightPanel from './PreflightPanel';
interface Finding {
@@ -54,7 +55,11 @@ function report(partial: Partial<Report>): Report {
// every call site listing the new field names).
if (partial.status !== undefined && partial.activeStatus === undefined) merged.activeStatus = merged.status;
if (partial.highestSeverity !== undefined && partial.activeHighestSeverity === undefined) merged.activeHighestSeverity = merged.highestSeverity;
if (partial.findings !== undefined && partial.activeCount === undefined) merged.activeCount = merged.findings.filter(f => !f.acknowledged).length;
if (partial.findings !== undefined && partial.activeCount === undefined) {
merged.activeCount = merged.findings.filter(
f => !f.acknowledged && !isPreflightNoteFinding(f.ruleId),
).length;
}
return merged;
}
@@ -79,6 +84,61 @@ describe('PreflightPanel', () => {
expect(status).toHaveTextContent(/all clear/i);
});
it('keeps All Clear when only inherited-healthcheck notes remain', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'pass',
activeStatus: 'pass',
activeCount: 0,
findings: [{
ruleId: 'healthcheck-inherited',
severity: 'info',
title: 'Healthcheck inherited from image',
message: 'Service "web" does not declare a healthcheck in Compose.',
service: 'web',
}],
})));
render(<PreflightPanel stackName="web" canEdit />);
const status = await screen.findByTestId('preflight-status');
expect(status).toHaveAttribute('data-status', 'pass');
expect(status).toHaveTextContent(/all clear/i);
expect(screen.getByTestId('preflight-notes-section')).toHaveTextContent(/Healthcheck inherited from image/i);
expect(screen.queryByTestId('preflight-ack-btn-healthcheck-inherited-web')).not.toBeInTheDocument();
});
it('excludes notes from the graded summary line when issue findings remain', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'warning',
highestSeverity: 'warning',
activeStatus: 'warning',
activeHighestSeverity: 'warning',
activeCount: 1,
findings: [
{
ruleId: 'image-latest',
severity: 'warning',
title: 'Image uses a moving tag',
message: 'latest tag',
service: 'web',
},
{
ruleId: 'healthcheck-inherited',
severity: 'info',
title: 'Healthcheck inherited from image',
message: 'Service "web" does not declare a healthcheck in Compose.',
service: 'web',
},
],
})));
render(<PreflightPanel stackName="web" canEdit />);
const status = await screen.findByTestId('preflight-status');
expect(status).toHaveAttribute('data-status', 'warning');
expect(status).toHaveTextContent(/1 warning/i);
expect(status).not.toHaveTextContent(/info/i);
expect(screen.getByTestId('preflight-notes-section')).toBeInTheDocument();
expect(screen.queryByTestId('preflight-ack-btn-healthcheck-inherited-web')).not.toBeInTheDocument();
expect(screen.getByTestId('preflight-ack-btn-image-latest-web')).toBeInTheDocument();
});
it('groups findings and reflects the highest severity', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'high',
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { isPreflightNoteFinding } from '@/lib/preflightNotes';
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
type PreflightStatus = 'never-run' | 'pass' | 'unrenderable' | PreflightSeverity;
@@ -70,7 +71,10 @@ const EXPIRY_LABELS: Record<PreflightAckExpiryMode, string> = {
until_image_change: 'Until image changes',
};
function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon; tone: string; line: string } {
function summaryMeta(
report: PreflightReport,
activeFindings: PreflightFinding[],
): { label: string; icon: LucideIcon; tone: string; line: string } {
if (!report.renderable) {
return {
label: 'cannot render',
@@ -84,7 +88,7 @@ function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon
}
const meta = SEVERITY_META[report.activeHighestSeverity ?? 'info'];
const activeParts = GROUP_ORDER
.map(sev => ({ sev, n: report.findings.filter(f => !f.acknowledged && f.severity === sev).length }))
.map(sev => ({ sev, n: activeFindings.filter(f => f.severity === sev).length }))
.filter(c => c.n > 0)
.map(c => `${c.n} ${SEVERITY_META[c.sev].label}`)
.join(' · ');
@@ -267,16 +271,19 @@ export default function PreflightPanel({ stackName, canEdit = false }: { stackNa
}
};
const activeFindings = useMemo(
() => report?.findings.filter(f => !f.acknowledged) ?? [],
[report?.findings],
);
const acknowledgedFindings = useMemo(
() => report?.findings.filter(f => f.acknowledged) ?? [],
[report?.findings],
);
const { activeFindings, noteFindings, acknowledgedFindings } = useMemo(() => {
const notes: PreflightFinding[] = [];
const active: PreflightFinding[] = [];
const acknowledged: PreflightFinding[] = [];
for (const f of report?.findings ?? []) {
if (isPreflightNoteFinding(f.ruleId)) notes.push(f);
else if (f.acknowledged) acknowledged.push(f);
else active.push(f);
}
return { activeFindings: active, noteFindings: notes, acknowledgedFindings: acknowledged };
}, [report?.findings]);
const summary = report && report.status !== 'never-run' ? summaryMeta(report) : null;
const summary = report && report.status !== 'never-run' ? summaryMeta(report, activeFindings) : null;
const SummaryIcon = summary?.icon;
const busy = loading || running;
@@ -418,6 +425,21 @@ export default function PreflightPanel({ stackName, canEdit = false }: { stackNa
</div>
)}
{noteFindings.length > 0 && (
<section data-testid="preflight-notes-section">
<div className={cn(LABEL_CLASS, 'mb-1.5')}>notes · {noteFindings.length}</div>
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
{noteFindings.map((f, i) => (
<FindingRow
key={`note-${f.ruleId}-${f.service ?? ''}-${i}`}
finding={f}
canEdit={false}
/>
))}
</div>
</section>
)}
{GROUP_ORDER.map(sev => {
const items = activeFindings.filter(f => f.severity === sev);
if (items.length === 0) return null;