mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
Harden discovery assurance feedback
This commit is contained in:
parent
b0de28c7c4
commit
31a9bcebf5
3 changed files with 341 additions and 98 deletions
|
|
@ -1,13 +1,17 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "defc24af837b91428fbee939d09cd31e9559fb4f",
|
||||
"verified_at": "2026-08-30T18:33:03Z",
|
||||
"base_sha": "b0de28c7c406468310dfbef95b00bd71400d8abb",
|
||||
"verified_at": "2026-08-30T19:10:01Z",
|
||||
"result": "passed",
|
||||
"changed_paths": ["frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx"],
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx": "8432b423e90ebb0e8dbc56ae82535b992bb7715be688cf6f95be34c1d12564e4"
|
||||
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx": "0e3c4c5c2bb63ac5c8cd2549cb1fccea3b17d86a8bffa4dd7d0bf0aca7f45000"
|
||||
},
|
||||
"routes": ["/docker/overview?q=uptime-kuma"],
|
||||
"routes": [
|
||||
"/browser-assurance-harness.html (temporary current-build Vite harness for the /docker/overview discovery proposal card)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
|
|
@ -19,14 +23,18 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"current-build AvailabilityProposalCard with a discovered Uptime Kuma verification proposal",
|
||||
"inline proposal evidence and canonical attachment copy",
|
||||
"open machine assurance suggestions dialog with one reviewable HTTP service",
|
||||
"desktop and phone-width proposal card and dialog layouts"
|
||||
"existing-check inventory loading, failure, retry recovery, empty inventory, and post-create duplicate detection",
|
||||
"proposal test transport success, application failure, and request failure with scoped status or alert feedback",
|
||||
"created active-check confirmation alongside duplicate protection",
|
||||
"machine-review loading, populated, and load-failure states without false empty-state overlap",
|
||||
"settled desktop and narrow card/dialog pixels from the final current-source Vite render; managed full-app startup was blocked by unrelated pulse-pro go.mod drift"
|
||||
],
|
||||
"interactions": [
|
||||
"opened Review machine suggestions and verified the evidence-specific dismissal copy",
|
||||
"closed the machine suggestions dialog with Escape",
|
||||
"verified card and dialog bounds, wrapping, scrolling, and absence of horizontal overflow at desktop and phone widths"
|
||||
"confirmed Create active check stays disabled until existing-check inventory succeeds and retry restores it",
|
||||
"ran successful and failed Test proposal actions and confirmed focus returns after the native loading-button blur",
|
||||
"created an active check and confirmed status plus duplicate feedback",
|
||||
"opened machine review, checked loading and populated states, closed with Escape, and confirmed focus return",
|
||||
"reopened machine review on failure and confirmed proposal errors stay outside the dialog and no false empty state appears",
|
||||
"inspected desktop and 390x844 settled pixels, topmost dialog hit testing, bounds, clipping, scrolling, and horizontal overflow"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
const [saving, setSaving] = createSignal(false);
|
||||
const [testing, setTesting] = createSignal(false);
|
||||
const [updatingDisposition, setUpdatingDisposition] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [proposalError, setProposalError] = createSignal<string | null>(null);
|
||||
const [bulkReviewError, setBulkReviewError] = createSignal<string | null>(null);
|
||||
const [testResult, setTestResult] = createSignal<AvailabilityTestResponse | null>(null);
|
||||
const [createdTarget, setCreatedTarget] = createSignal<AvailabilityTarget | null>(null);
|
||||
const [showBulkReview, setShowBulkReview] = createSignal(false);
|
||||
|
|
@ -97,18 +98,20 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
setName(current.service_name || props.discovery.service_name || props.discovery.hostname);
|
||||
setTestResult(null);
|
||||
setCreatedTarget(null);
|
||||
setError(null);
|
||||
setProposalError(null);
|
||||
setBulkReviewError(null);
|
||||
});
|
||||
|
||||
const duplicate = createMemo(() => {
|
||||
const current = proposal();
|
||||
if (!current) return null;
|
||||
if (!current || targets.error) return null;
|
||||
return findAvailabilityProposalDuplicate(current, props.canonicalResourceId, targets() ?? []);
|
||||
});
|
||||
const dismissed = createMemo(() => isAvailabilityProposalDismissed(props.discovery));
|
||||
const bulkItems = createMemo(() =>
|
||||
reviewableAvailabilitySummaries(machineDiscoveries()?.discoveries ?? []),
|
||||
);
|
||||
const bulkItems = createMemo(() => {
|
||||
if (machineDiscoveries.error) return [];
|
||||
return reviewableAvailabilitySummaries(machineDiscoveries()?.discoveries ?? []);
|
||||
});
|
||||
|
||||
const buildTarget = (): AvailabilityTarget | null => {
|
||||
const current = proposal();
|
||||
|
|
@ -122,32 +125,33 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
});
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
const handleTest = async (trigger?: HTMLButtonElement) => {
|
||||
const target = buildTarget();
|
||||
if (!target) return;
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
setProposalError(null);
|
||||
setTestResult(null);
|
||||
try {
|
||||
setTestResult(await AvailabilityTargetsAPI.test(target));
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to test this proposal.');
|
||||
setProposalError(cause instanceof Error ? cause.message : 'Failed to test this proposal.');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
queueMicrotask(() => trigger?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const target = buildTarget();
|
||||
if (!target || duplicate()?.kind === 'endpoint') return;
|
||||
if (!target || targets.loading || targets.error || duplicate()?.kind === 'endpoint') return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setProposalError(null);
|
||||
try {
|
||||
const created = await AvailabilityTargetsAPI.create(target);
|
||||
setCreatedTarget(created);
|
||||
await refetchTargets();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to create this check.');
|
||||
setProposalError(cause instanceof Error ? cause.message : 'Failed to create this check.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -158,11 +162,11 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
if (!current) return;
|
||||
const fingerprint = current.evidence_fingerprint?.trim();
|
||||
if (!fingerprint) {
|
||||
setError('Run discovery again before changing this legacy suggestion.');
|
||||
setProposalError('Run discovery again before changing this legacy suggestion.');
|
||||
return;
|
||||
}
|
||||
setUpdatingDisposition(true);
|
||||
setError(null);
|
||||
setProposalError(null);
|
||||
try {
|
||||
const updated = await updateAvailabilityProposal(
|
||||
props.resourceType,
|
||||
|
|
@ -174,7 +178,7 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
props.onDiscoveryUpdated(updated);
|
||||
if (showBulkReview()) await refetchMachineDiscoveries();
|
||||
} catch (cause) {
|
||||
setError(
|
||||
setProposalError(
|
||||
cause instanceof Error ? cause.message : 'Failed to update this proposal review state.',
|
||||
);
|
||||
} finally {
|
||||
|
|
@ -190,11 +194,11 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
if (!current) return;
|
||||
const fingerprint = current.evidence_fingerprint?.trim();
|
||||
if (!fingerprint) {
|
||||
setError('Run discovery again before changing this legacy suggestion.');
|
||||
setBulkReviewError('Run discovery again before changing this legacy suggestion.');
|
||||
return;
|
||||
}
|
||||
setUpdatingDisposition(true);
|
||||
setError(null);
|
||||
setBulkReviewError(null);
|
||||
try {
|
||||
const updated = await updateAvailabilityProposal(
|
||||
summary.resource_type,
|
||||
|
|
@ -206,7 +210,9 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
if (summary.id === props.discovery.id) props.onDiscoveryUpdated(updated);
|
||||
await refetchMachineDiscoveries();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Failed to update the selected proposal.');
|
||||
setBulkReviewError(
|
||||
cause instanceof Error ? cause.message : 'Failed to update the selected proposal.',
|
||||
);
|
||||
} finally {
|
||||
setUpdatingDisposition(false);
|
||||
}
|
||||
|
|
@ -234,7 +240,14 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
Nothing is created until you choose the active-check action below.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowBulkReview(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setBulkReviewError(null);
|
||||
setShowBulkReview(true);
|
||||
}}
|
||||
>
|
||||
Review machine suggestions
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -330,13 +343,37 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
|
||||
<Show when={duplicate()}>
|
||||
{(match) => (
|
||||
<div class="mt-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div
|
||||
class="mt-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
|
||||
role="status"
|
||||
>
|
||||
{match().kind === 'endpoint'
|
||||
? `Already covered by “${match().target.name || match().target.address}”. Pulse will not create a duplicate endpoint check.`
|
||||
: `This resource already has “${match().target.name || match().target.address}”, but it covers a different endpoint. Review both before adding another.`}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={targets.loading}>
|
||||
<div class="mt-3 flex items-center gap-2 text-xs text-muted" role="status">
|
||||
<LoadingSpinner size="sm" /> Checking existing active checks…
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={targets.error}>
|
||||
<div class="mt-3 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-xs text-red-900 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200">
|
||||
<p role="alert">
|
||||
Pulse could not check for existing active checks. Creating a check is unavailable
|
||||
until that safety check succeeds.
|
||||
</p>
|
||||
<Button
|
||||
class="mt-2"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void refetchTargets()}
|
||||
>
|
||||
Retry existing-check scan
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={testResult()}>
|
||||
{(result) => (
|
||||
|
|
@ -346,6 +383,7 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
? 'border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-200'
|
||||
: 'border-red-300 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200'
|
||||
}`}
|
||||
role={result().success ? 'status' : 'alert'}
|
||||
>
|
||||
{testResultLabel(result())}
|
||||
</div>
|
||||
|
|
@ -353,15 +391,20 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
</Show>
|
||||
<Show when={createdTarget()}>
|
||||
{(created) => (
|
||||
<div class="mt-3 flex items-center gap-2 rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-xs text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
<div
|
||||
class="mt-3 flex items-center gap-2 rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-xs text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-200"
|
||||
role="status"
|
||||
>
|
||||
<CheckCircleIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Active check “{created().name}” created and attached to this resource.
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<Show when={proposalError()}>
|
||||
{(message) => (
|
||||
<p class="mt-3 text-xs font-medium text-red-700 dark:text-red-300">{message()}</p>
|
||||
<p class="mt-3 text-xs font-medium text-red-700 dark:text-red-300" role="alert">
|
||||
{message()}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
|
|
@ -370,7 +413,12 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
size="sm"
|
||||
variant="primary"
|
||||
isLoading={saving()}
|
||||
disabled={Boolean(createdTarget()) || duplicate()?.kind === 'endpoint'}
|
||||
disabled={
|
||||
targets.loading ||
|
||||
Boolean(targets.error) ||
|
||||
Boolean(createdTarget()) ||
|
||||
duplicate()?.kind === 'endpoint'
|
||||
}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
Create active check
|
||||
|
|
@ -379,7 +427,7 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
size="sm"
|
||||
variant="secondary"
|
||||
isLoading={testing()}
|
||||
onClick={() => void handleTest()}
|
||||
onClick={(event) => void handleTest(event.currentTarget)}
|
||||
>
|
||||
Test proposal
|
||||
</Button>
|
||||
|
|
@ -424,69 +472,82 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
|
|||
</div>
|
||||
<div class="space-y-3 px-5 py-4">
|
||||
<Show when={machineDiscoveries.loading}>
|
||||
<div class="flex items-center gap-2 py-8 text-sm text-muted">
|
||||
<div class="flex items-center gap-2 py-8 text-sm text-muted" role="status">
|
||||
<LoadingSpinner size="md" /> Loading discovered services…
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={bulkItems().length > 0}
|
||||
fallback={
|
||||
<p class="rounded-md border border-dashed border-border p-4 text-sm text-muted">
|
||||
No availability suggestions are currently available for this machine.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<For each={bulkItems()}>
|
||||
{(item) => {
|
||||
const itemProposal = () => item.suggested_availability_probe!;
|
||||
const itemDismissed = () => isAvailabilityProposalDismissed(item);
|
||||
return (
|
||||
<article class="rounded-md border border-border bg-surface p-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPinIcon
|
||||
class="h-4 w-4 shrink-0 text-blue-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 class="truncate text-sm font-semibold text-base-content">
|
||||
{item.service_name || item.hostname || item.resource_id}
|
||||
</h3>
|
||||
<Show when={itemDismissed()}>
|
||||
<span class="rounded-full bg-surface-hover px-2 py-0.5 text-[10px] text-muted">
|
||||
Dismissed
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<code class="mt-1 block break-all text-xs text-muted">
|
||||
{endpointLabel(itemProposal())}
|
||||
</code>
|
||||
<p class="mt-1 text-[11px] text-muted">
|
||||
{expectedBehaviorLabel(itemProposal())} · {itemProposal().reason}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={itemDismissed() ? 'secondary' : 'ghost'}
|
||||
disabled={updatingDisposition()}
|
||||
onClick={() =>
|
||||
void updateBulkDisposition(
|
||||
item,
|
||||
itemDismissed() ? 'reviewable' : 'dismissed',
|
||||
)
|
||||
}
|
||||
>
|
||||
{itemDismissed() ? 'Restore' : 'Dismiss'}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={machineDiscoveries.error}>
|
||||
<p
|
||||
class="rounded-md border border-red-300 bg-red-50 p-4 text-sm text-red-900 dark:border-red-800 dark:bg-red-950/30 dark:text-red-200"
|
||||
role="alert"
|
||||
>
|
||||
Pulse could not load this machine’s assurance suggestions. Close this review and
|
||||
try again.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<Show when={!machineDiscoveries.loading && !machineDiscoveries.error}>
|
||||
<Show
|
||||
when={bulkItems().length > 0}
|
||||
fallback={
|
||||
<p class="rounded-md border border-dashed border-border p-4 text-sm text-muted">
|
||||
No availability suggestions are currently available for this machine.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<For each={bulkItems()}>
|
||||
{(item) => {
|
||||
const itemProposal = () => item.suggested_availability_probe!;
|
||||
const itemDismissed = () => isAvailabilityProposalDismissed(item);
|
||||
return (
|
||||
<article class="rounded-md border border-border bg-surface p-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPinIcon
|
||||
class="h-4 w-4 shrink-0 text-blue-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 class="truncate text-sm font-semibold text-base-content">
|
||||
{item.service_name || item.hostname || item.resource_id}
|
||||
</h3>
|
||||
<Show when={itemDismissed()}>
|
||||
<span class="rounded-full bg-surface-hover px-2 py-0.5 text-[10px] text-muted">
|
||||
Dismissed
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<code class="mt-1 block break-all text-xs text-muted">
|
||||
{endpointLabel(itemProposal())}
|
||||
</code>
|
||||
<p class="mt-1 text-[11px] text-muted">
|
||||
{expectedBehaviorLabel(itemProposal())} · {itemProposal().reason}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={itemDismissed() ? 'secondary' : 'ghost'}
|
||||
disabled={updatingDisposition()}
|
||||
onClick={() =>
|
||||
void updateBulkDisposition(
|
||||
item,
|
||||
itemDismissed() ? 'reviewable' : 'dismissed',
|
||||
)
|
||||
}
|
||||
>
|
||||
{itemDismissed() ? 'Restore' : 'Dismiss'}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={bulkReviewError()}>
|
||||
{(message) => (
|
||||
<p class="text-sm font-medium text-red-700 dark:text-red-300">{message()}</p>
|
||||
<p class="text-sm font-medium text-red-700 dark:text-red-300" role="alert">
|
||||
{message()}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AvailabilityTargetsAPI } from '@/api/availabilityTargets';
|
||||
import { listDiscoveriesByAgent, updateAvailabilityProposal } from '@/api/discovery';
|
||||
|
|
@ -120,6 +120,9 @@ describe('AvailabilityProposalCard', () => {
|
|||
await fireEvent.change(screen.getByLabelText('Observation location · you control'), {
|
||||
target: { value: 'edge-1' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Create active check' })).toBeEnabled(),
|
||||
);
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Create active check' }));
|
||||
|
||||
await waitFor(() => expect(AvailabilityTargetsAPI.create).toHaveBeenCalledTimes(1));
|
||||
|
|
@ -135,7 +138,10 @@ describe('AvailabilityProposalCard', () => {
|
|||
}),
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(/created and attached to this resource/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/created and attached to this resource/i)).toHaveAttribute(
|
||||
'role',
|
||||
'status',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks an equivalent standalone endpoint and records evidence-bound dismissal', async () => {
|
||||
|
|
@ -164,7 +170,10 @@ describe('AvailabilityProposalCard', () => {
|
|||
));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/already covered by “Existing Grafana”/i)).toBeInTheDocument(),
|
||||
expect(screen.getByText(/already covered by “Existing Grafana”/i)).toHaveAttribute(
|
||||
'role',
|
||||
'status',
|
||||
),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create active check' })).toBeDisabled();
|
||||
|
||||
|
|
@ -180,4 +189,169 @@ describe('AvailabilityProposalCard', () => {
|
|||
);
|
||||
expect(onDiscoveryUpdated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps creation unavailable until duplicate checking succeeds', async () => {
|
||||
let resolveTargets!: (targets: []) => void;
|
||||
vi.mocked(AvailabilityTargetsAPI.list).mockImplementation(
|
||||
() => new Promise((resolve) => (resolveTargets = resolve)),
|
||||
);
|
||||
|
||||
render(() => (
|
||||
<AvailabilityProposalCard
|
||||
discovery={discovery()}
|
||||
resourceType="app-container"
|
||||
targetId="agent-1"
|
||||
resourceId="grafana"
|
||||
canonicalResourceId="resource:grafana"
|
||||
onDiscoveryUpdated={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Checking existing active checks…')).toHaveAttribute('role', 'status');
|
||||
const createButton = screen.getByRole('button', { name: 'Create active check' });
|
||||
expect(createButton).toBeDisabled();
|
||||
await fireEvent.click(createButton);
|
||||
expect(AvailabilityTargetsAPI.create).not.toHaveBeenCalled();
|
||||
|
||||
resolveTargets([]);
|
||||
await waitFor(() => expect(createButton).toBeEnabled());
|
||||
});
|
||||
|
||||
it('fails duplicate checking closed and explains why creation is unavailable', async () => {
|
||||
vi.mocked(AvailabilityTargetsAPI.list)
|
||||
.mockRejectedValueOnce(new Error('inventory unavailable'))
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
render(() => (
|
||||
<AvailabilityProposalCard
|
||||
discovery={discovery()}
|
||||
resourceType="app-container"
|
||||
targetId="agent-1"
|
||||
resourceId="grafana"
|
||||
canonicalResourceId="resource:grafana"
|
||||
onDiscoveryUpdated={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
const failure = await screen.findByText(/could not check for existing active checks/i);
|
||||
expect(failure).toHaveAttribute('role', 'alert');
|
||||
const createButton = screen.getByRole('button', { name: 'Create active check' });
|
||||
expect(createButton).toBeDisabled();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Retry existing-check scan' }));
|
||||
await waitFor(() => expect(createButton).toBeEnabled());
|
||||
});
|
||||
|
||||
it('announces proposal test success and failure without moving focus', async () => {
|
||||
let resolveTest!: (result: {
|
||||
success: true;
|
||||
latencyMillis: number;
|
||||
application: { outcome: 'passed'; statusCode: number };
|
||||
}) => void;
|
||||
vi.mocked(AvailabilityTargetsAPI.test).mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveTest = resolve)),
|
||||
);
|
||||
|
||||
render(() => (
|
||||
<AvailabilityProposalCard
|
||||
discovery={discovery()}
|
||||
resourceType="app-container"
|
||||
targetId="agent-1"
|
||||
resourceId="grafana"
|
||||
canonicalResourceId="resource:grafana"
|
||||
onDiscoveryUpdated={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
const testButton = screen.getByRole('button', { name: 'Test proposal' });
|
||||
testButton.focus();
|
||||
await fireEvent.click(testButton);
|
||||
testButton.blur();
|
||||
resolveTest({
|
||||
success: true,
|
||||
latencyMillis: 18,
|
||||
application: { outcome: 'passed', statusCode: 200 },
|
||||
});
|
||||
const success = await screen.findByText(/application response passed with HTTP 200/i);
|
||||
expect(success).toHaveAttribute('role', 'status');
|
||||
expect(document.activeElement).toBe(testButton);
|
||||
|
||||
vi.mocked(AvailabilityTargetsAPI.test).mockResolvedValueOnce({
|
||||
success: false,
|
||||
latencyMillis: 0,
|
||||
error: 'Connection refused',
|
||||
});
|
||||
await fireEvent.click(testButton);
|
||||
const failure = await screen.findByText('Connection refused');
|
||||
expect(failure).toHaveAttribute('role', 'alert');
|
||||
expect(document.activeElement).toBe(testButton);
|
||||
});
|
||||
|
||||
it('does not claim machine suggestions are empty while they are still loading', async () => {
|
||||
let resolveDiscoveries!: (result: { discoveries: []; total: 0 }) => void;
|
||||
vi.mocked(listDiscoveriesByAgent).mockImplementation(
|
||||
() => new Promise((resolve) => (resolveDiscoveries = resolve)),
|
||||
);
|
||||
|
||||
render(() => (
|
||||
<AvailabilityProposalCard
|
||||
discovery={discovery()}
|
||||
resourceType="app-container"
|
||||
targetId="agent-1"
|
||||
resourceId="grafana"
|
||||
canonicalResourceId="resource:grafana"
|
||||
onDiscoveryUpdated={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Review machine suggestions' }));
|
||||
expect(screen.getByText('Loading discovered services…')).toHaveAttribute('role', 'status');
|
||||
expect(screen.queryByText(/no availability suggestions are currently available/i)).toBeNull();
|
||||
|
||||
resolveDiscoveries({ discoveries: [], total: 0 });
|
||||
expect(
|
||||
await screen.findByText(/no availability suggestions are currently available/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not leak a proposal action failure into machine review', async () => {
|
||||
vi.mocked(AvailabilityTargetsAPI.test).mockRejectedValueOnce(new Error('Probe test failed'));
|
||||
|
||||
render(() => (
|
||||
<AvailabilityProposalCard
|
||||
discovery={discovery()}
|
||||
resourceType="app-container"
|
||||
targetId="agent-1"
|
||||
resourceId="grafana"
|
||||
canonicalResourceId="resource:grafana"
|
||||
onDiscoveryUpdated={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Test proposal' }));
|
||||
expect(await screen.findByText('Probe test failed')).toHaveAttribute('role', 'alert');
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Review machine suggestions' }));
|
||||
expect(within(screen.getByRole('dialog')).queryByText('Probe test failed')).toBeNull();
|
||||
});
|
||||
|
||||
it('reports machine suggestion load failures instead of showing an empty state', async () => {
|
||||
vi.mocked(listDiscoveriesByAgent).mockRejectedValue(new Error('discovery unavailable'));
|
||||
|
||||
render(() => (
|
||||
<AvailabilityProposalCard
|
||||
discovery={discovery()}
|
||||
resourceType="app-container"
|
||||
targetId="agent-1"
|
||||
resourceId="grafana"
|
||||
canonicalResourceId="resource:grafana"
|
||||
onDiscoveryUpdated={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Review machine suggestions' }));
|
||||
const failure = await screen.findByText(/could not load this machine’s assurance suggestions/i);
|
||||
expect(failure).toHaveAttribute('role', 'alert');
|
||||
expect(screen.queryByText(/no availability suggestions are currently available/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue