mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user