Add the HarvestView web application (#2512)

* feat: add HarvestView operator UI

* Use JSONL-only HarvestView file flows

* Drive HarvestView activities from the API catalog

* Add result action controls

* Render shared DNS resolver defaults

* Clarify resolver cardinality in HarvestView

* Load Tabulator from CDNjs

* Fix HarvestView wiki link

* Align HarvestView with execution status contract

* Add HarvestView import and action controls

* Harden HarvestView browser assertions

* Simplify HarvestView run selection

* Show truthful HarvestView execution outcomes

* refactor: show canonical URL results in HarvestView

* Show canonical hostname results in HarvestView

* test(harvestview): remove browser error race
This commit is contained in:
Matt
2026-08-10 00:08:05 -04:00
committed by GitHub
parent 1fd5749e52
commit 0d2ab5a0b3
24 changed files with 3736 additions and 13 deletions
+62
View File
@@ -0,0 +1,62 @@
name: HarvestView browser E2E
on:
workflow_dispatch:
pull_request:
paths:
- '.github/workflows/harvestview-e2e.yml'
- 'pyproject.toml'
- 'uv.lock'
- 'theHarvester/lib/api/**'
- 'tests/e2e/**'
- 'tests/lib/test_api_v1.py'
- 'tests/lib/test_harvestview_ui.py'
permissions:
contents: read
jobs:
harvestview-e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install uv and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: '3.13'
enable-cache: true
cache-dependency-glob: uv.lock
- name: Install project dependencies
run: uv sync --all-groups --frozen
- name: Install Chromium
run: uv run playwright install --with-deps chromium
- name: Run HarvestView browser tests
run: >-
uv run pytest -m harvestview_e2e
--browser chromium
--tracing=retain-on-failure
--screenshot=only-on-failure
--output=test-results
- name: Upload browser test artifacts
if: ${{ failure() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: harvestview-browser-test-results
path: test-results/
if-no-files-found: warn
retention-days: 7
+35 -1
View File
@@ -2,6 +2,7 @@
*.pyc
*.sqlite
*.html
!theHarvester/lib/api/static/harvestview/index.html
*.htm
*.vscode
*.xml
@@ -18,4 +19,37 @@ api-keys.yaml
.venv
.venv/**
.pyre
.junie
.junie
.env
.env.*
.secrets/
test-results/
# impeccable-ignore-start
# Ephemeral output, runtime state, and per-dev overrides.
# Unanchored: .impeccable may sit at the repo root or under a nested
# workspace (apps/web/.impeccable/...); anchored patterns would miss it.
# Shared artifacts stay tracked: config.json, live/config.json,
# design.json, critique/*.md.
**/.impeccable/config.local.json
**/.impeccable/hook.cache.json
**/.impeccable/hook.pending.json
**/.impeccable/*.png
**/.impeccable/live/server.json
**/.impeccable/live/sessions/
**/.impeccable/live/previews/
**/.impeccable/live/annotations/
**/.impeccable/live/cache/
**/.impeccable/live/manual-edit-apply-transaction.json
**/.impeccable/live/manual-edit-events.jsonl
**/.impeccable/live/manual-edit-evidence/
**/.impeccable/live/pending-manual-edits.json
**/.impeccable/live/deferred-svelte-component-accepts.json
**/.impeccable/live/*.png
# impeccable-ignore-end
# Shared Impeccable artifacts override the repository-wide *.json rule.
!**/.impeccable/config.json
!**/.impeccable/live/config.json
!**/.impeccable/design.json
!**/.impeccable/critique/*.md
+57
View File
@@ -0,0 +1,57 @@
# HarvestView design system
## Direction
Use the approved “Run desk” direction: a dark mineral navigation rail, warm neutral work surface, teal operational accent, amber warnings, and restrained red failures. The interface should feel like a field notebook crossed with a reliable control room-not a generic SaaS card grid.
## Typography
Use the local system stack only. Display and headings use a compact humanist sans stack; evidence values, IDs, timestamps, and status metadata use the system monospace stack. Body text stays at 16px on small screens and line length stays below 72 characters where prose appears.
## Layout
Desktop uses a fixed app header, a history rail, and one flexible evidence workbench. Result routes use a single table surface rather than nested cards. Tablet collapses secondary metadata. Mobile stacks history above evidence, preserves all actions, and keeps touch targets at least 44px.
## Color tokens
Use OKLCH tokens for background, surface, ink, muted text, line, teal accent, amber warning, red danger, and blue information. Light and dark themes must both meet WCAG AA contrast. Status always includes text or an icon as well as color.
## Interaction
Use native dialogs, buttons, inputs, details, and file controls. Motion is limited to short opacity/transform transitions for dialogs, notices, and selection; reduced-motion removes transforms and durations. Focus rings are never suppressed. Dynamic status changes use a polite live region.
## Tables and evidence
Use the pinned CDNjs Tabulator browser build for sorting, filtering, selection, and pagination. Load only its default table theme, with HarvestView's own stylesheet controlling the visual system. DNS status uses resolved, no-answer, disputed, uncertain, and not-captured labels. Long values wrap or truncate with a title; they never break the viewport.
## CSS architecture
HarvestView uses its own `app.css` and native HTML controls. A general UI framework
would not make the interface better by itself; it would replace the existing Run
Desk visual language with framework defaults or require the same custom overrides
again.
| Option | Benefit | Cost for HarvestView | Decision |
| --- | --- | --- | --- |
| Custom CSS | Keeps the existing visual system, native controls, and zero-build workflow. | HarvestView owns its small reset and component rules. | Use. |
| Bootstrap | Mature components, utilities, and documentation. | No Bootstrap APIs are used; adding them would duplicate 232 KB of styles and make the interface more generic. | Remove. |
| Pico | Small class-light API and sensible semantic defaults. | Its global element styles compete with the existing native-control and theme rules. | Do not add. |
| Bulma | CSS-only component classes. | Requires a markup rewrite and adds a larger stylesheet without improving the evidence workflow. | Do not add. |
| Tailwind | Strong utility workflow and small compiled output when a build step is used. | Requires a markup rewrite and build pipeline; its browser CDN is development-only. | Do not add. |
Tabulator is the exception because it supplies table behavior HarvestView actually
uses. Its JavaScript and pinned default theme load from CDNjs by default with
Subresource Integrity. Isolated deployments can self-host those exact assets by
following the installation wiki. `app.css` owns the visual treatment on top of
that structural theme.
Primary references: [Bootstrap 5.3 installation](https://getbootstrap.com/docs/5.3/getting-started/introduction/),
[Tabulator 6.x installation](https://tabulator.info/docs/6.x/install),
[Tabulator 6.x themes](https://tabulator.info/docs/6.x/theme),
[Pico quick start](https://github.com/picocss/pico#quick-start),
[Bulma quick install](https://github.com/jgthms/bulma#quick-install), and
[Tailwind Play CDN guidance](https://tailwindcss.com/docs/installation/play-cdn).
## Voice
Use precise operator language: “Start enumeration,” “Request cancellation,” “Import result file,” and “No runs yet.” Errors state what failed and the next action. Avoid scan, session, job, and vague success/error labels where the glossary has a precise term.
+33
View File
@@ -0,0 +1,33 @@
# HarvestView product context
## Product
HarvestView is the local web application for running theHarvester and analyzing its results. It turns finite enumeration runs and imported result files into durable, searchable evidence without turning theHarvester into a monitoring service.
## Operator
The operator is a technically capable security practitioner working at a desk, often under time pressure. They need to see exactly what was authorized, what ran, what failed, and what evidence remains.
## Core jobs
- Launch one explicitly authorized enumeration with P0/P1/P2 boundaries visible before execution.
- Cancel work and know whether cancellation is requested, in progress, or complete.
- Reopen prior runs and compare route-specific evidence without rerunning reconnaissance.
- Import existing theHarvester JSONL evidence.
- Export normalized results and inspect managed screenshots.
- Start a screenshot or DNS brute-force action from a subdomain result without changing the parent evidence.
## Product principles
- Local-first and fail-closed.
- Evidence before decoration.
- Passive by default; active work is explicit.
- One finite run at a time.
- Honest state: lifecycle status and evidence completeness are different facts.
- Dense enough for an expert, calm enough for long sessions.
## Platform
web
The app is served by the existing local FastAPI application. Desktop is primary; tablet and mobile remain fully operable.
+17 -4
View File
@@ -79,19 +79,30 @@ uv run theHarvester -h
Options such as DNS brute force (`-c`), bounded recursive DNS (`--dns-recursive-depth`), reverse DNS lookup (`-n`), takeover checks (`-t`), API endpoint scanning (`-a`), DNS resolution (`-r`), and screenshots (`--screenshot`) generate additional network activity. Use them only within an explicitly authorized scope.
Recursive DNS requires exactly three distinct resolver IPs through `--dns-resolvers` or the compatible `--dns-resolve` value. It advances only names with two-vantage address consensus that are distinguishable from closest-encloser wildcard controls. Depth, DNS record query, and runtime limits are configurable through the three `--dns-recursive-*` options; the default query ceiling is 3,000 record queries across resolver vantages, and three consecutive zero-yield batches also stop recursion. PTR names for current addresses are retained as secondary evidence, but they do not establish current addressability or become recursion seeds. `POST /api/v1/runs` exposes the same controls.
Recursive DNS requires exactly three distinct resolver IPs through `--dns-resolvers` or the compatible `--dns-resolve` value. It advances only names with two-vantage address consensus that are distinguishable from closest-encloser wildcard controls. Depth, DNS record query, and runtime limits are configurable through the three `--dns-recursive-*` options; the default query ceiling is 3,000 record queries across resolver vantages, and three consecutive zero-yield batches also stop recursion. PTR names for current addresses are retained as secondary evidence, but they do not establish current addressability or become recursion seeds. HarvestView and `POST /api/v1/runs` expose the same controls.
Screenshot capture also requires a Playwright-compatible browser; see the installation guide for setup.
## REST API
## HarvestView and REST API
`restfulHarvest` starts a FastAPI service on `127.0.0.1:5000` by default:
```bash
export THEHARVESTER_API_KEY='replace-with-a-long-random-value'
uv run restfulHarvest
```
Open [http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs) for interactive Swagger documentation or [http://127.0.0.1:5000/redoc](http://127.0.0.1:5000/redoc) for ReDoc.
Open [HarvestView](http://127.0.0.1:5000/) to run and inspect finite enumerations in the local web app. The server gives the local browser a derived HttpOnly session cookie, so the API key is never entered into or stored by HarvestView.
HarvestView uses its own `app.css` rather than a general UI framework. Bootstrap,
Bulma, Pico, and Tailwind would duplicate the existing design layer or require a
markup and build-pipeline rewrite. Tabulator 6.5.2's table behavior and default
theme load from pinned CDNjs URLs with Subresource Integrity. HarvestView
therefore needs network access to CDNjs by default. See the
[self-hosting instructions](docs/wiki/Installation.md) for
an isolated deployment.
Open [Swagger](http://127.0.0.1:5000/docs) or [ReDoc](http://127.0.0.1:5000/redoc) for the automation contract.
| Route | Purpose |
| --- | --- |
@@ -104,7 +115,9 @@ Open [http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs) for interactive Sw
| `POST /api/v1/runs/import-database` | Import completed runs from a theHarvester SQLite database. |
| `GET /api/v1/runs/{run_id}/export` | Export normalized evidence as JSONL. |
Every `/api/v1/*` route requires `THEHARVESTER_API_KEY` in the `X-API-Key` header. Provider credentials stay in server-side configuration and cannot be supplied in a request. Keep the service bound to localhost. If you require remote access, add network access controls and TLS.
HarvestView can start a screenshot or DNS brute-force run directly from a hostname result. These actions create a separate run record for that hostname and leave the parent evidence unchanged. Resolver addresses may be entered directly or loaded from a text file with one IP address per line. Ordinary DNS actions accept one or more resolvers; recursive DNS requires exactly three.
API clients send `THEHARVESTER_API_KEY` in the `X-API-Key` header; HarvestView uses its derived browser cookie. Provider credentials stay in server-side configuration and cannot be supplied in a request. Keep the service bound to localhost. If you require remote access, add network access controls and TLS.
When `--proxies` and `--take-over` are combined, supported discovery and takeover requests use the configured proxies.
+1 -1
View File
@@ -72,4 +72,4 @@ export THEHARVESTER_API_KEY='replace-with-a-long-random-value'
uv run restfulHarvest
```
API clients send the same value in the `X-API-Key` header. Provider credentials remain in `api-keys.yaml` and cannot be supplied through an API request.
API clients send the same value in the `X-API-Key` header. HarvestView receives a derived HttpOnly browser cookie when it is opened locally, so the key is never entered into or stored by the web app. Provider credentials remain in `api-keys.yaml` and cannot be supplied through an API request.
+18
View File
@@ -35,6 +35,24 @@ uv run pytest
The supported console commands are `theHarvester` and `restfulHarvest`. There is no root `theHarvester.py` launcher.
### Self-host Tabulator
HarvestView loads Tabulator 6.5.2 from CDNjs by default. For an isolated deployment, download the same pinned files in a connected environment and copy them into `theHarvester/lib/api/static/harvestview/`:
| File | CDNjs source | SRI |
| --- | --- | --- |
| `tabulator.min.css` | `https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/css/tabulator.min.css` | `sha512-t8I/asqzdu/MRgVLxVanQ/c5bhUA1qZ/zA432a/3nUh0kkd7P8Qch35wQvTODivf9D6Xv3h7F8p7ezcUyBOQrQ==` |
| `tabulator.min.js` | `https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/js/tabulator.min.js` | `sha512-AF0YMSgc0Ui4IJPb4hJNSi16wFidZEQa6ZTCAeguF3h5glVnAPuz/JT2ai9ypKhsc9n6CEXBB+tMdxsv1q+rxg==` |
Then replace the two CDNjs tags in `theHarvester/lib/api/static/harvestview/index.html` with same-origin references:
```html
<link rel="stylesheet" href="/static/harvestview/tabulator.min.css?v=6.5.2">
<script src="/static/harvestview/tabulator.min.js?v=6.5.2"></script>
```
Remove the CDN-only `integrity`, `crossorigin`, and `referrerpolicy` attributes from those local tags. Rebuild the package or container after copying the assets.
### Screenshot support
The screenshot option requires a Playwright-compatible Chromium browser:
+1 -1
View File
@@ -37,6 +37,6 @@ Results may contain private infrastructure, employee addresses, account identifi
## API exposure
Every `/api/v1/*` route requires `THEHARVESTER_API_KEY`. Provider credentials remain server-side.
Every `/api/v1/*` route requires `THEHARVESTER_API_KEY`. HarvestView is restricted to a loopback browser origin and uses a derived HttpOnly cookie for those same routes. Provider credentials remain server-side.
Keep the service on localhost. If you require remote access, add network controls and TLS in front of the existing API authentication.
+6 -1
View File
@@ -1,6 +1,6 @@
# REST API
`restfulHarvest` serves one versioned API for local automation.
`restfulHarvest` serves HarvestView at `/` and one versioned API for local automation.
## Start the service
@@ -15,6 +15,7 @@ The service binds to `127.0.0.1:5000` by default. Use `uv run restfulHarvest -h`
Open:
- HarvestView: [http://127.0.0.1:5000/](http://127.0.0.1:5000/)
- Swagger UI: [http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs)
- ReDoc: [http://127.0.0.1:5000/redoc](http://127.0.0.1:5000/redoc)
@@ -48,6 +49,8 @@ curl -s http://127.0.0.1:5000/api/v1/sources \
| jq
```
HarvestView receives a derived HttpOnly browser-session cookie when loaded from localhost. The browser never stores or displays the configured API key. Cookie-authenticated mutations also require a matching same-origin request.
Provider credentials remain in theHarvester's server-side configuration. Requests cannot supply provider API keys.
## Submit and inspect a run
@@ -108,6 +111,8 @@ The action catalog and run request use the same names. For example, set `takeove
Every custom API scan entry must be a URL path beginning with `/`. The API does not accept a server-side file path.
HarvestView's subdomain action buttons call this route and create a separate run without changing the completed parent run.
## Import and export
Import records existing evidence and never contacts the target. For one run, send the same JSONL written by `theHarvester -f NAME`:
+1 -1
View File
@@ -82,7 +82,7 @@ uv run restfulHarvest --log-level debug
Then open [http://127.0.0.1:5000/docs](http://127.0.0.1:5000/docs).
- `401` on `/api/v1/*`: the `X-API-Key` header does not match.
- `401` on `/api/v1/*`: the `X-API-Key` header or HarvestView browser session does not match.
- `503` on `/api/v1/*`: `THEHARVESTER_API_KEY` was not configured before startup.
- `429`: a reverse proxy or remote provider applied its own rate limit. `restfulHarvest` has no built-in request limiter.
- `503` when creating a run: the execution worker is disabled or unavailable.
+3 -1
View File
@@ -51,6 +51,7 @@ dev = [
"mypy-extensions==1.1.0",
"pytest==9.1.1",
"pytest-asyncio==1.4.0",
"pytest-playwright==0.8.0",
"types-certifi==2021.10.8.3",
"types-chardet==5.0.4.6",
"types-python-dateutil==2.9.0.20260518",
@@ -69,9 +70,10 @@ restfulHarvest = "theHarvester.restfulHarvest:main"
minversion = "8.3.3"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = "--no-header --strict-markers"
addopts = "--no-header --strict-markers -m 'not harvestview_e2e'"
markers = [
"live_network: contacts an external service and runs only with --run-live-network",
"harvestview_e2e: real-browser tests against an isolated local HarvestView server",
]
testpaths = ["tests"]
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import os
import subprocess
import sys
import time
from collections import Counter
from ipaddress import ip_address
from pathlib import Path
from typing import TYPE_CHECKING, TextIO
from urllib.parse import urlsplit
import httpx
import pytest
if TYPE_CHECKING:
from playwright.sync_api import Page, Response, Route
CDNJS_TABULATOR_ASSETS = {
'https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/css/tabulator.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/js/tabulator.min.js',
}
class HarvestViewServer:
def __init__(self, repo_root: Path, port: int, environment: dict[str, str], server_log: Path) -> None:
self.repo_root = repo_root
self.port = port
self.environment = environment
self.server_log = server_log
self.url = f'http://127.0.0.1:{port}'
self._output: TextIO | None = None
self._process: subprocess.Popen[str] | None = None
def start(self) -> None:
self.server_log.parent.mkdir(parents=True, exist_ok=True)
self._output = self.server_log.open('a', encoding='utf-8')
self._process = subprocess.Popen(
[
sys.executable,
'-m',
'uvicorn',
'theHarvester.lib.api.api:app',
'--host',
'127.0.0.1',
'--port',
str(self.port),
'--log-level',
'warning',
],
cwd=self.repo_root,
env=self.environment,
stdout=self._output,
stderr=subprocess.STDOUT,
text=True,
)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if self._process.poll() is not None:
self.stop()
pytest.fail(f'theHarvester test server exited during startup; see {self.server_log}')
try:
if httpx.get(f'{self.url}/openapi.json', timeout=0.25).status_code == 200:
return
except httpx.HTTPError:
time.sleep(0.05)
self.stop()
pytest.fail(f'theHarvester test server did not become ready; see {self.server_log}')
def stop(self) -> None:
if self._process is not None and self._process.poll() is None:
self._process.terminate()
try:
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait(timeout=5)
self._process = None
if self._output is not None:
self._output.close()
self._output = None
def restart(self) -> None:
self.stop()
self.start()
class BrowserFailures:
def __init__(self, page: Page) -> None:
self.console_errors: Counter[str] = Counter()
self.local_failures: Counter[tuple[str, int, str]] = Counter()
self.external_requests: list[str] = []
self.allowed_console_errors: Counter[str] = Counter()
self.allowed_responses: Counter[tuple[str, int, str]] = Counter()
page.on('console', self._record_console_message)
page.on('response', self._record_response)
page.route('**/*', self._guard_request)
def _record_console_message(self, message) -> None:
if message.type == 'error':
self.console_errors[message.text] += 1
def _guard_request(self, route: Route) -> None:
request = route.request
parsed = urlsplit(request.url)
if request.url in CDNJS_TABULATOR_ASSETS:
route.continue_()
return
if parsed.scheme not in {'http', 'https'} or parsed.hostname == 'localhost':
route.continue_()
return
try:
is_loopback = parsed.hostname is not None and ip_address(parsed.hostname).is_loopback
except ValueError:
is_loopback = False
if is_loopback:
route.continue_()
return
self.external_requests.append(f'{request.method} {request.url}')
route.abort('blockedbyclient')
def _record_response(self, response: Response) -> None:
path = urlsplit(response.url).path
if response.status >= 500 or (path.startswith('/api/v1/') and response.status == 401):
self.local_failures[(response.request.method, response.status, path)] += 1
def allow_response(self, method: str, status_code: int, path: str) -> None:
self.allowed_responses[(method, status_code, path)] += 1
def allow_console_error(self, message: str) -> None:
self.allowed_console_errors[message] += 1
def assert_clean(self) -> None:
assert self.console_errors - self.allowed_console_errors == Counter()
assert self.local_failures == self.allowed_responses
assert self.external_requests == []
@pytest.fixture(autouse=True)
def browser_failures(page: Page) -> BrowserFailures:
failures = BrowserFailures(page)
yield failures
failures.assert_clean()
@pytest.fixture
def harvestview_server(tmp_path: Path, unused_tcp_port: int) -> HarvestViewServer:
repo_root = Path(__file__).parents[2]
artifact_dir = repo_root / 'test-results'
artifact_dir.mkdir(exist_ok=True)
server_log = artifact_dir / f'harvestview-server-{unused_tcp_port}.log'
environment = os.environ.copy()
environment.update(
{
'THEHARVESTER_API_KEY': 'harvestview-e2e-key',
'THEHARVESTER_RUN_ARTIFACTS': str(tmp_path / 'artifacts'),
'THEHARVESTER_RUN_DB': str(tmp_path / 'runs.sqlite'),
'THEHARVESTER_RUN_WORKER': 'disabled',
'ALL_PROXY': 'http://127.0.0.1:9',
'HTTPS_PROXY': 'http://127.0.0.1:9',
'HTTP_PROXY': 'http://127.0.0.1:9',
'NO_PROXY': '127.0.0.1,localhost',
'all_proxy': 'http://127.0.0.1:9',
'https_proxy': 'http://127.0.0.1:9',
'http_proxy': 'http://127.0.0.1:9',
'no_proxy': '127.0.0.1,localhost',
}
)
server = HarvestViewServer(repo_root, unused_tcp_port, environment, server_log)
server.start()
try:
yield server
finally:
server.stop()
@pytest.fixture
def harvestview_server_url(harvestview_server: HarvestViewServer) -> str:
return harvestview_server.url
+961
View File
@@ -0,0 +1,961 @@
from __future__ import annotations
import asyncio
import json
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from pathlib import Path
import pytest
from playwright.sync_api import Page, Route, expect
pytestmark = pytest.mark.harvestview_e2e
def write_jsonl_evidence(path: Path, evidence: dict[str, object]) -> None:
results = evidence.get('results', [])
assert isinstance(results, list)
counts = Counter(str(result['type']) for result in results if isinstance(result, dict))
summary = {
'type': 'summary',
'run_id': evidence['run_id'],
'target': evidence['target'],
'started_at': evidence['started_at'],
'completed_at': evidence['completed_at'],
'evidence_status': evidence['status'],
'source_executions': evidence.get('source_executions', []),
'action_executions': evidence.get('action_executions', []),
'artifacts': evidence.get('artifacts', []),
'result_count': len(results),
'counts': dict(sorted(counts.items())),
}
records = [
summary,
*[
{**result, 'sources': result.get('sources', []), 'actions': result.get('actions', [])}
for result in results
if isinstance(result, dict)
],
]
path.write_text(''.join(json.dumps(record, sort_keys=True) + '\n' for record in records), encoding='utf-8')
def record_api_statuses(page: Page, server_url: str) -> dict[str, int]:
api_statuses: dict[str, int] = {}
def record_response(response) -> None:
if response.url.startswith(f'{server_url}/api/v1/'):
api_statuses[response.url.removeprefix(server_url)] = response.status
page.on('response', record_response)
return api_statuses
def test_fresh_browser_session_authenticates_through_harvestview(
harvestview_server_url: str,
page: Page,
) -> None:
api_statuses = record_api_statuses(page, harvestview_server_url)
page.goto(f'{harvestview_server_url}/')
expect(page.get_by_role('heading', name='No enumeration runs yet')).to_be_visible()
assert api_statuses['/api/v1/sources'] == 200
assert api_statuses['/api/v1/runs'] == 200
expect(page.get_by_text('Invalid API key')).to_have_count(0)
def test_browser_session_stays_authenticated_after_server_restart(
harvestview_server,
page: Page,
) -> None:
page.goto(f'{harvestview_server.url}/')
expect(page.get_by_role('heading', name='No enumeration runs yet')).to_be_visible()
harvestview_server.restart()
page.reload()
expect(page.get_by_role('heading', name='No enumeration runs yet')).to_be_visible()
expect(page.get_by_text('Invalid API key')).to_have_count(0)
@pytest.mark.parametrize('viewport', [{'width': 1440, 'height': 900}, {'width': 390, 'height': 844}])
def test_versioned_assets_and_tooltips_work_at_supported_viewports(
harvestview_server_url: str,
page: Page,
viewport: dict[str, int],
) -> None:
page.set_viewport_size(viewport)
page.goto(f'{harvestview_server_url}/')
stylesheet = page.locator('link[href*="/static/harvestview/app.css"]')
script = page.locator('script[src*="/static/harvestview/app.js"]')
assert '?v=' in (stylesheet.get_attribute('href') or '')
assert '?v=' in (script.get_attribute('src') or '')
assert '{{' not in (stylesheet.get_attribute('href') or '')
assert '{{' not in (script.get_attribute('src') or '')
page.get_by_role('button', name='Start enumeration').first.click()
tooltip = page.get_by_role('button', name='Explain discovery sources')
tooltip.hover()
page.wait_for_function(
"node => getComputedStyle(node, '::after').opacity === '1'",
arg=tooltip.element_handle(),
)
tooltip_content = tooltip.evaluate("node => getComputedStyle(node, '::after').content")
assert 'Credential warnings identify sources that cannot start' in tooltip_content
assert 'Credential warnings identify sources that cannot start' in tooltip.get_attribute('aria-description')
assert tooltip.bounding_box()['width'] >= 44
assert tooltip.bounding_box()['height'] >= 44
assert page.locator('#submit-run-button').evaluate(
"""button => {
const buttonBox = button.getBoundingClientRect();
const dialogBox = button.closest('dialog').getBoundingClientRect();
return buttonBox.top >= dialogBox.top && buttonBox.bottom <= dialogBox.bottom && buttonBox.bottom <= innerHeight;
}"""
)
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth')
def test_disabled_worker_rejects_submission_without_creating_a_run(
harvestview_server_url: str,
page: Page,
browser_failures,
) -> None:
browser_failures.allow_response('POST', 503, '/api/v1/runs')
browser_failures.allow_console_error(
'Failed to load resource: the server responded with a status of 503 (Service Unavailable)'
)
page.goto(f'{harvestview_server_url}/')
page.get_by_role('button', name='Start enumeration').first.click()
page.locator('#run-target').fill('example.com')
with page.expect_response(
lambda response: response.url == f'{harvestview_server_url}/api/v1/runs' and response.request.method == 'POST'
) as submission:
page.locator('#submit-run-button').click()
assert submission.value.status == 503
expect(page.locator('#new-run-error')).to_have_text('theHarvester execution worker is disabled')
history = page.context.request.get(f'{harvestview_server_url}/api/v1/runs')
assert history.status == 200
assert history.json() == []
def test_harvestview_can_submit_overridable_execution_controls(
harvestview_server_url: str,
page: Page,
browser_failures,
tmp_path: Path,
) -> None:
browser_failures.allow_response('POST', 503, '/api/v1/runs')
browser_failures.allow_console_error(
'Failed to load resource: the server responded with a status of 503 (Service Unavailable)'
)
captured: dict[str, object] = {}
def capture_submission(route: Route) -> None:
if route.request.method != 'POST':
route.continue_()
return
captured.update(route.request.post_data_json)
route.fulfill(status=503, json={'detail': 'Controls captured'})
page.route(f'{harvestview_server_url}/api/v1/runs', capture_submission)
page.goto(f'{harvestview_server_url}/')
page.set_default_timeout(2_000)
page.get_by_role('button', name='Start enumeration').first.click()
page.get_by_role('button', name='Clear', exact=True).click()
page.locator('#run-target').fill('example.com')
page.get_by_text('Advanced execution controls', exact=True).click()
page.locator('#run-start').fill('25')
page.locator('#run-deadline').fill('86400')
page.locator('[name="proxies"]').check()
page.locator('[name="shodan"]').check()
page.locator('[name="dns_lookup"]').check()
page.locator('[name="takeover"]').check()
page.locator('[name="api_scan"]').check()
page.locator('#api-scan-paths').fill('/api/v2\n/health')
page.locator('#dns-recursive-depth').fill('3')
page.locator('#dns-recursive-query-limit').fill('1234')
page.locator('#dns-recursive-runtime-seconds').fill('12.5')
resolver_file = tmp_path / 'resolvers.txt'
resolver_file.write_text('192.0.2.53\n198.51.100.53\n203.0.113.53\n', encoding='utf-8')
page.locator('#dns-resolver-file').set_input_files(resolver_file)
expect(page.locator('#dns-resolvers')).to_have_value('192.0.2.53,198.51.100.53,203.0.113.53')
expect(page.locator('#activity-summary')).to_have_text('P0 selected · P1 selected · P2 selected')
page.locator('[data-activity="P0"] input[value="crtsh"]').check()
page.locator('#submit-run-button').click()
expect(page.locator('#new-run-error')).to_have_text('Controls captured')
assert captured == {
'target': 'example.com',
'sources': ['crtsh'],
'limit': 500,
'start': 25,
'deadline_seconds': 86_400,
'proxies': True,
'dns_lookup': True,
'dns_resolve': False,
'dns_resolvers': ['192.0.2.53', '198.51.100.53', '203.0.113.53'],
'dns_recursive_depth': 3,
'dns_recursive_query_limit': 1_234,
'dns_recursive_runtime_seconds': 12.5,
'dns_brute': False,
'shodan': True,
'screenshot': False,
'takeover': True,
'api_scan': True,
'api_scan_paths': ['/api/v2', '/health'],
}
def test_harvestview_submits_a_target_only_api_scan(
harvestview_server_url: str,
page: Page,
browser_failures,
) -> None:
browser_failures.allow_response('POST', 503, '/api/v1/runs')
browser_failures.allow_console_error(
'Failed to load resource: the server responded with a status of 503 (Service Unavailable)'
)
captured: dict[str, object] = {}
def capture_submission(route: Route) -> None:
if route.request.method != 'POST':
route.continue_()
return
captured.update(route.request.post_data_json)
route.fulfill(status=503, json={'detail': 'Target-only action captured'})
page.route(f'{harvestview_server_url}/api/v1/runs', capture_submission)
page.goto(f'{harvestview_server_url}/')
page.get_by_role('button', name='Start enumeration').first.click()
page.get_by_role('button', name='Clear', exact=True).click()
page.locator('#run-target').fill('api.example.test')
page.locator('[name="api_scan"]').check()
page.locator('details.advanced-execution summary').click()
page.locator('#api-scan-paths').fill('/api/v2\n/health')
page.locator('#submit-run-button').click()
expect(page.locator('#new-run-error')).to_have_text('Target-only action captured')
assert captured['sources'] == []
assert captured['api_scan'] is True
assert captured['api_scan_paths'] == ['/api/v2', '/health']
def test_hostname_actions_queue_isolated_runs(
harvestview_server_url: str,
page: Page,
browser_failures,
) -> None:
browser_failures.allow_response('POST', 503, '/api/v1/runs')
browser_failures.allow_response('POST', 503, '/api/v1/runs')
browser_failures.allow_console_error(
'Failed to load resource: the server responded with a status of 503 (Service Unavailable)'
)
browser_failures.allow_console_error(
'Failed to load resource: the server responded with a status of 503 (Service Unavailable)'
)
run = {
'run_id': 'parent-run',
'target': 'example.com',
'status': 'completed',
'origin': 'local',
'created_at': '2026-08-05T12:00:00+00:00',
'started_at': '2026-08-05T12:00:01+00:00',
'completed_at': '2026-08-05T12:00:05+00:00',
'cancellation_requested_at': None,
'evidence_status': 'complete',
'result_count': 1,
'activities': ['P0'],
'sources': ['crtsh'],
'request': {
'sources': ['crtsh'],
'limit': 25,
'deadline_seconds': 300,
'dns_resolvers': ['192.0.2.53'],
},
'source_executions': [],
'action_executions': [
{
'action': 'dns-brute',
'status': 'failed',
'result_count': 0,
'duration_ms': 125,
'error_type': 'TimeoutError',
'stop_reason': 'query-errors',
}
],
'results': [{'type': 'hostname', 'value': 'api.example.com'}],
'screenshots': [],
'log': '',
'error': None,
}
submissions: list[dict[str, object]] = []
def route_runs(route: Route) -> None:
if route.request.method == 'POST':
submission = route.request.post_data_json
submissions.append(submission)
detail = 'Screenshot captured' if submission.get('screenshot') else 'DNS brute captured'
route.fulfill(status=503, json={'detail': detail})
else:
route.fulfill(json=[run])
page.route(f'{harvestview_server_url}/api/v1/runs', route_runs)
page.route(f'{harvestview_server_url}/api/v1/runs/parent-run', lambda route: route.fulfill(json=run))
page.goto(f'{harvestview_server_url}/')
expect(page.get_by_role('button', name='Hostnames 1')).to_be_enabled()
page.locator('#provider-details summary').click()
expect(page.locator('#provider-title')).to_have_text('Execution outcomes')
action_row = page.locator('#provider-body tr').filter(has_text='dns-brute')
expect(action_row).to_contain_text('Action')
expect(action_row).to_contain_text('failed')
expect(action_row).to_contain_text('TimeoutError')
page.get_by_role('button', name='Take screenshot of api.example.com (P2)').click()
expect(page.locator('#toast')).to_contain_text('Screenshot captured')
page.get_by_role('button', name='DNS brute force api.example.com (P1)').click()
expect(page.locator('#toast')).to_contain_text('DNS brute captured')
assert submissions == [
{'target': 'api.example.com', 'sources': [], 'screenshot': True},
{
'target': 'api.example.com',
'sources': [],
'dns_brute': True,
'dns_resolvers': ['192.0.2.53'],
},
]
def test_accepted_result_action_is_not_reported_as_failed_when_refresh_fails(
harvestview_server_url: str,
page: Page,
browser_failures,
) -> None:
browser_failures.allow_response('GET', 503, '/api/v1/runs')
browser_failures.allow_console_error(
'Failed to load resource: the server responded with a status of 503 (Service Unavailable)'
)
run = {
'run_id': 'parent-run',
'target': 'example.com',
'status': 'running',
'origin': 'local',
'created_at': '2026-08-05T12:00:00+00:00',
'started_at': '2026-08-05T12:00:01+00:00',
'completed_at': None,
'cancellation_requested_at': None,
'evidence_status': 'partial',
'result_count': 1,
'activities': ['P0'],
'sources': ['crtsh'],
'request': {'sources': ['crtsh'], 'limit': 25, 'deadline_seconds': 300},
'source_executions': [],
'action_executions': [],
'results': [{'type': 'hostname', 'value': 'api.example.com'}],
'screenshots': [],
'log': '',
'error': None,
}
list_calls = 0
post_calls = 0
cancelled_ids: list[str] = []
def route_runs(route: Route) -> None:
nonlocal list_calls, post_calls
if route.request.method == 'POST':
post_calls += 1
route.fulfill(status=201, json={'run_id': 'queued-action', 'target': 'api.example.com', 'status': 'queued'})
return
list_calls += 1
if list_calls == 1:
route.fulfill(json=[run])
elif list_calls == 2:
route.fulfill(status=503, json={'detail': 'Refresh unavailable'})
else:
route.fulfill(json=[{**run, 'status': 'cancelled', 'completed_at': '2026-08-05T12:00:06+00:00'}])
def route_cancel(route: Route, run_id: str) -> None:
cancelled_ids.append(run_id)
route.fulfill(json={**run, 'status': 'cancelled', 'completed_at': '2026-08-05T12:00:06+00:00'})
page.route(f'{harvestview_server_url}/api/v1/runs', route_runs)
page.route(
f'{harvestview_server_url}/api/v1/runs/parent-run/cancel',
lambda route: route_cancel(route, 'parent-run'),
)
page.route(
f'{harvestview_server_url}/api/v1/runs/queued-action/cancel',
lambda route: route_cancel(route, 'queued-action'),
)
page.route(f'{harvestview_server_url}/api/v1/runs/parent-run', lambda route: route.fulfill(json=run))
page.goto(f'{harvestview_server_url}/')
page.get_by_role('button', name='Take screenshot of api.example.com (P2)').click()
expect(page.locator('#toast')).to_contain_text('was queued, but the run view could not refresh')
expect(page.locator('#toast')).to_contain_text('Do not submit it again')
expect(page.locator('#detail-run-id')).to_have_text('parent-run')
page.locator('#cancel-run-button').click()
expect(page.locator('#toast')).to_have_text('Queued enumeration cancelled.')
assert post_calls == 1
assert cancelled_ids == ['parent-run']
def test_accepted_cancellation_is_not_reported_as_failed_when_history_refresh_fails(
harvestview_server_url: str,
page: Page,
browser_failures,
) -> None:
browser_failures.allow_response('GET', 503, '/api/v1/runs')
browser_failures.allow_console_error(
'Failed to load resource: the server responded with a status of 503 (Service Unavailable)'
)
run = {
'run_id': 'running-run',
'target': 'example.com',
'status': 'running',
'origin': 'local',
'created_at': '2026-08-05T12:00:00+00:00',
'started_at': '2026-08-05T12:00:01+00:00',
'completed_at': None,
'cancellation_requested_at': None,
'evidence_status': 'partial',
'result_count': 0,
'activities': ['P0'],
'sources': ['crtsh'],
'request': {'sources': ['crtsh'], 'limit': 25, 'deadline_seconds': 300},
'source_executions': [],
'action_executions': [],
'results': [],
'screenshots': [],
'log': '',
'error': None,
}
list_calls = 0
cancel_calls = 0
def route_runs(route: Route) -> None:
nonlocal list_calls
list_calls += 1
if list_calls == 1:
route.fulfill(json=[run])
else:
route.fulfill(status=503, json={'detail': 'Refresh unavailable'})
def route_cancel(route: Route) -> None:
nonlocal cancel_calls
cancel_calls += 1
route.fulfill(json={**run, 'status': 'cancelling', 'cancellation_requested_at': '2026-08-05T12:00:02+00:00'})
page.route(f'{harvestview_server_url}/api/v1/runs', route_runs)
page.route(f'{harvestview_server_url}/api/v1/runs/running-run/cancel', route_cancel)
page.route(f'{harvestview_server_url}/api/v1/runs/running-run', lambda route: route.fulfill(json=run))
page.goto(f'{harvestview_server_url}/')
page.locator('#cancel-run-button').click()
expect(page.locator('#toast')).to_contain_text('Cancellation was accepted, but run history could not refresh')
expect(page.locator('#toast')).to_contain_text('Do not request it again')
assert cancel_calls == 1
def test_harvestview_can_select_sources_by_result_capability(harvestview_server_url: str, page: Page) -> None:
page.goto(f'{harvestview_server_url}/')
catalog = page.context.request.get(f'{harvestview_server_url}/api/v1/sources').json()
expected = {source['name'] for source in catalog['sources'] if 'ips' in source['capabilities']}
page.get_by_role('button', name='Start enumeration').first.click()
page.get_by_role('button', name='Clear', exact=True).click()
page.locator('#source-capability').select_option('ips')
page.get_by_role('button', name='Add sources').click()
selected = set(page.locator('#source-groups input:checked').evaluate_all('(inputs) => inputs.map(input => input.value)'))
assert selected == expected
def test_polling_recovers_after_one_transient_refresh_failure(
harvestview_server_url: str,
page: Page,
browser_failures,
) -> None:
browser_failures.allow_response('GET', 503, '/api/v1/runs/retry-run')
browser_failures.allow_console_error(
'Failed to load resource: the server responded with a status of 503 (Service Unavailable)'
)
run = {
'run_id': 'retry-run',
'target': 'example.com',
'status': 'running',
'origin': 'local',
'created_at': '2026-08-05T12:00:00+00:00',
'started_at': '2026-08-05T12:00:01+00:00',
'completed_at': None,
'cancellation_requested_at': None,
'evidence_status': 'partial',
'result_count': 0,
'activities': ['P0'],
'sources': ['crtsh'],
'request': {'sources': ['crtsh'], 'limit': 25, 'deadline_seconds': 300},
'source_executions': [],
'results': [],
'screenshots': [],
'log': '',
'error': None,
}
detail_calls = 0
def route_runs(route: Route) -> None:
route.fulfill(json=[run])
def route_detail(route: Route) -> None:
nonlocal detail_calls
detail_calls += 1
if detail_calls == 2:
route.fulfill(status=503, json={'detail': 'Temporary refresh failure'})
else:
completed = detail_calls >= 3
route.fulfill(
json={
**run,
'status': 'completed' if completed else 'running',
'completed_at': '2026-08-05T12:00:05+00:00' if completed else None,
'evidence_status': 'complete' if completed else 'partial',
}
)
page.route(f'{harvestview_server_url}/api/v1/runs', route_runs)
page.route(f'{harvestview_server_url}/api/v1/runs/retry-run', route_detail)
page.goto(f'{harvestview_server_url}/')
expect(page.locator('#status-chips')).to_contain_text('completed', timeout=7_000)
assert detail_calls >= 3
def test_workspace_startup_failure_has_an_inline_retry(
harvestview_server_url: str,
page: Page,
browser_failures,
) -> None:
browser_failures.allow_response('GET', 503, '/api/v1/sources')
browser_failures.allow_console_error(
'Failed to load resource: the server responded with a status of 503 (Service Unavailable)'
)
source_calls = 0
def fail_once(route: Route) -> None:
nonlocal source_calls
source_calls += 1
if source_calls == 1:
route.fulfill(status=503, json={'detail': 'Temporary startup failure'})
else:
route.continue_()
page.route(f'{harvestview_server_url}/api/v1/sources', fail_once)
page.goto(f'{harvestview_server_url}/')
page.set_default_timeout(3_000)
expect(page.locator('#workspace-error')).to_be_visible()
expect(page.locator('#workspace-error-message')).to_have_text('Temporary startup failure')
page.locator('#retry-workspace-button').click()
expect(page.get_by_role('heading', name='No enumeration runs yet')).to_be_visible()
assert source_calls == 2
def test_unchanged_poll_keeps_result_table_filters(harvestview_server_url: str, page: Page) -> None:
run = {
'run_id': 'stable-run',
'target': 'example.com',
'status': 'running',
'origin': 'local',
'created_at': '2026-08-05T12:00:00+00:00',
'started_at': '2026-08-05T12:00:01+00:00',
'completed_at': None,
'cancellation_requested_at': None,
'evidence_status': 'partial',
'result_count': 1,
'activities': ['P0', 'P2'],
'sources': ['crtsh'],
'request': {
'sources': ['crtsh'],
'limit': 25,
'deadline_seconds': 300,
'proxies': True,
'dns_lookup': True,
'takeover': True,
},
'source_executions': [],
'results': [{'type': 'hostname', 'value': 'api.example.com'}],
'screenshots': [],
'log': '',
'error': None,
}
page.route(f'{harvestview_server_url}/api/v1/runs', lambda route: route.fulfill(json=[run]))
page.route(f'{harvestview_server_url}/api/v1/runs/stable-run', lambda route: route.fulfill(json=run))
page.goto(f'{harvestview_server_url}/')
expect(page.locator('#request-options')).to_contain_text('Proxy transportSelected')
expect(page.locator('#request-options')).to_contain_text('DNS lookup (/24 reverse expansion)Selected')
expect(page.locator('#request-options')).to_contain_text('Takeover transportConfigured proxy')
value_filter = page.locator('.tabulator-col[tabulator-field="value"] .tabulator-header-filter input')
value_filter.fill('api')
page.wait_for_timeout(1_600)
expect(value_filter).to_have_value('api')
@pytest.mark.parametrize('initial_detail_status', ['queued', 'running'])
def test_submission_notification_matches_the_loaded_lifecycle(
harvestview_server_url: str,
page: Page,
initial_detail_status: str,
) -> None:
page.goto(f'{harvestview_server_url}/')
queued_run = {
'run_id': 'toast-run',
'target': 'example.com',
'status': 'queued',
'origin': 'local',
'created_at': '2026-08-05T12:00:00+00:00',
'started_at': None,
'completed_at': None,
'cancellation_requested_at': None,
'evidence_status': 'partial',
'result_count': 0,
'activities': ['P0'],
'sources': ['crtsh'],
'request': {'sources': ['crtsh'], 'limit': 25, 'deadline_seconds': 300},
'source_executions': [],
'results': [],
'screenshots': [],
'log': '',
'error': None,
}
detail_status = initial_detail_status
def route_runs(route: Route) -> None:
if route.request.method == 'POST':
route.fulfill(status=201, json=queued_run)
else:
route.fulfill(json=[{**queued_run, 'status': 'running', 'started_at': '2026-08-05T12:00:01+00:00'}])
def route_detail(route: Route) -> None:
if detail_status == 'queued':
run = queued_run
elif detail_status == 'running':
run = {
**queued_run,
'status': 'running',
'started_at': '2026-08-05T12:00:01+00:00',
}
else:
run = {
**queued_run,
'status': 'cancelling',
'started_at': '2026-08-05T12:00:01+00:00',
'cancellation_requested_at': '2026-08-05T12:00:02+00:00',
}
route.fulfill(json=run)
page.route(f'{harvestview_server_url}/api/v1/runs', route_runs)
page.route(f'{harvestview_server_url}/api/v1/runs/toast-run', route_detail)
page.get_by_role('button', name='Start enumeration').first.click()
page.locator('#run-target').fill('example.com')
page.locator('#run-limit').fill('25')
page.locator('#run-deadline').fill('300')
page.locator('#submit-run-button').click()
if initial_detail_status == 'queued':
expect(page.locator('#toast')).to_have_text('Enumeration for example.com is queued.')
detail_status = 'running'
expect(page.locator('#status-chips')).to_contain_text('running')
expect(page.locator('#submit-run-button')).to_be_enabled()
expect(page.locator('#toast')).to_be_hidden(timeout=2500)
detail_status = 'cancelling'
expect(page.locator('#lifecycle-track strong')).to_contain_text(['Cancellation requested'])
@pytest.mark.parametrize(
('terminal_status', 'title', 'copy'),
[
('cancelled', 'Enumeration cancelled', 'The enumeration was cancelled.'),
('failed', 'Enumeration failed', 'Provider process exited.'),
],
)
def test_empty_terminal_run_explains_its_lifecycle(
harvestview_server_url: str,
page: Page,
terminal_status: str,
title: str,
copy: str,
) -> None:
run = {
'run_id': f'{terminal_status}-run',
'target': 'example.com',
'status': terminal_status,
'origin': 'local',
'created_at': '2026-08-05T12:00:00+00:00',
'started_at': '2026-08-05T12:00:01+00:00',
'completed_at': '2026-08-05T12:00:02+00:00',
'cancellation_requested_at': None,
'evidence_status': 'partial',
'result_count': 0,
'activities': ['P0'],
'sources': ['crtsh'],
'request': {'sources': ['crtsh'], 'limit': 25, 'deadline_seconds': 300},
'source_executions': [],
'results': [],
'screenshots': [],
'log': '',
'error': 'Provider process exited.' if terminal_status == 'failed' else None,
}
page.route(f'{harvestview_server_url}/api/v1/runs', lambda route: route.fulfill(json=[run]))
page.route(f'{harvestview_server_url}/api/v1/runs/{run["run_id"]}', lambda route: route.fulfill(json=run))
page.goto(f'{harvestview_server_url}/')
expect(page.locator('#results-empty-title')).to_have_text(title)
expect(page.locator('#results-empty-copy')).to_contain_text(copy)
expect(page.locator('#results-empty-copy')).to_contain_text('The retained evidence record is partial.')
def test_completed_empty_import_explains_terminal_outcome(
harvestview_server_url: str,
page: Page,
tmp_path: Path,
) -> None:
evidence_file = tmp_path / 'empty-run.jsonl'
write_jsonl_evidence(
evidence_file,
{
'run_id': '4ce79bb1-91a1-4456-8589-e5d82b55f2b4',
'target': 'example.com',
'started_at': '2026-08-05T12:00:00+00:00',
'completed_at': '2026-08-05T12:00:25+00:00',
'status': 'complete',
'source_executions': [
{'source': 'crtsh', 'status': 'completed', 'result_count': 0, 'duration_ms': 25000},
],
'results': [],
},
)
page.goto(f'{harvestview_server_url}/')
page.get_by_role('button', name='Import result file').first.click()
page.locator('#result-file').set_input_files(evidence_file)
page.locator('#submit-import-button').click()
expect(page.locator('#results-empty-title')).to_have_text('Enumeration completed')
expect(page.locator('#results-summary')).to_have_text(
'0 normalized results · 1 completed (1 zero-result) / 0 partial / 0 skipped / 0 failed.'
)
expect(page.locator('#results-empty-copy')).to_have_text(
'crtsh returned no normalized evidence. The retained evidence record is complete.'
)
expect(page.locator('#lifecycle-track strong')).to_have_text(['Submitted', 'Started', 'Completed'])
expect(page.get_by_role('button', name='All JSONL')).to_be_enabled()
with page.expect_download() as jsonl_download:
page.get_by_role('button', name='All JSONL').click()
exported_records = [json.loads(line) for line in Path(jsonl_download.value.path()).read_text(encoding='utf-8').splitlines()]
assert len(exported_records) == 1
assert exported_records[0]['evidence_status'] == 'complete'
assert exported_records[0]['result_count'] == 0
assert exported_records[0]['source_executions'][0]['status'] == 'completed'
def test_harvestview_imports_completed_runs_from_sqlite(
harvestview_server_url: str,
page: Page,
tmp_path: Path,
) -> None:
from theHarvester.lib.completed_result import CompletedResult
from theHarvester.lib.database import ResultStore, dispose_sqlite_databases
database = tmp_path / 'completed-runs.sqlite'
now = datetime.now(UTC)
completed = CompletedResult.finish(
target='sqlite.example.test',
started_at=now,
completed_at=now,
groups={'hostname': ['api.sqlite.example.test']},
)
async def prepare_database() -> None:
store = ResultStore(database)
await store.initialize()
await store.save_run(completed)
await dispose_sqlite_databases()
with ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(lambda: asyncio.run(prepare_database())).result()
page.goto(f'{harvestview_server_url}/')
page.get_by_role('button', name='Import result file').first.click()
page.locator('#result-file').set_input_files(database)
page.locator('#submit-import-button').click()
expect(page.locator('#detail-target')).to_have_text('sqlite.example.test')
expect(page.locator('#run-count')).to_have_text('1')
expect(page.locator('#toast')).to_have_text('Imported 1 run from completed-runs.sqlite; 0 already present.')
expect(page.get_by_role('button', name='Hostnames 1')).to_be_enabled()
def test_harvestview_can_import_and_analyze_fixture_evidence_through_the_real_ui(
harvestview_server_url: str,
page: Page,
tmp_path: Path,
) -> None:
page.set_viewport_size({'width': 1440, 'height': 900})
page.context.grant_permissions(['clipboard-read', 'clipboard-write'], origin=harvestview_server_url)
page.goto(f'{harvestview_server_url}/')
catalog = page.context.request.get(f'{harvestview_server_url}/api/v1/sources').json()
passive_sources = [source for source in catalog['sources'] if source['activity'] == 'P0']
credentialed = [source for source in passive_sources if source['credentials']]
uncredentialed = [source for source in passive_sources if not source['credentials']]
ordered_sources = uncredentialed[:23] + credentialed[:2] + uncredentialed[23:] + credentialed[2:]
assert len(ordered_sources) == len(passive_sources)
assert ordered_sources[23]['credentials']
assert ordered_sources[24]['credentials']
evidence = {
'run_id': '7bb74ee1-c81c-4ccd-8ec7-e8e496490f53',
'target': 'example.com',
'started_at': '2026-08-04T12:00:01+00:00',
'completed_at': '2026-08-04T12:03:21+00:00',
'status': 'partial',
'source_executions': [
{
'source': source['name'],
'status': 'completed' if index < 23 else 'skipped',
'result_count': 1 if index < 3 else 0,
'duration_ms': index + 1,
**({'error_type': 'SourceDidNotStart'} if index >= 23 else {}),
**({'stop_reason': 'missing-credentials'} if index == 23 else {}),
}
for index, source in enumerate(ordered_sources)
],
'results': [
{
'type': result_type,
'value': '192.0.2.10' if result_type == 'ip' else f'{result_type}.example.com',
'sources': [ordered_sources[index]['name']] if index < 3 else [],
}
for index, result_type in enumerate(('hostname', 'ip', 'asn', 'email', 'url', 'framework', 'person', 'language'))
]
+ [
{
'type': 'ip',
'value': '198.51.100.10',
}
],
}
evidence_file = tmp_path / 'broad-run.jsonl'
write_jsonl_evidence(evidence_file, evidence)
page.get_by_role('button', name='Import result file').first.click()
page.locator('#result-file').set_input_files(evidence_file)
page.locator('#submit-import-button').click()
expect(page.locator('#provider-outcome-summary')).to_have_text(
f'23 completed (20 zero-result) / 0 partial / {len(ordered_sources) - 23} skipped / 0 failed'
)
expect(page.locator('#run-count')).to_have_text('1')
expect(page.locator('#run-list button')).to_have_count(1)
assert page.locator('#results-title').evaluate(
"node => Boolean(node.compareDocumentPosition(document.querySelector('#provider-title')) & Node.DOCUMENT_POSITION_FOLLOWING)"
)
assert page.locator('#results-title').evaluate(
"node => Boolean(node.compareDocumentPosition(document.querySelector('#lifecycle-title')) & Node.DOCUMENT_POSITION_FOLLOWING)"
)
assert page.locator('#results-title').evaluate(
"node => Boolean(node.compareDocumentPosition(document.querySelector('#run-facts')) & Node.DOCUMENT_POSITION_FOLLOWING)"
)
page.locator('.provider-details summary').click()
missing_credentials_row = page.locator('#provider-body tr').filter(has_text=ordered_sources[23]['name'])
expect(missing_credentials_row.locator('td').last).to_have_text(
'Required credentials were not configured; add them, then retry.'
)
unexpected_skip_reason = page.locator('#provider-body tr').filter(has_text=ordered_sources[24]['name']).locator('td').last
expect(unexpected_skip_reason).to_contain_text('Credentials required:')
expect(unexpected_skip_reason).to_contain_text('Source did not start')
page.locator('#history-search').fill('not-present')
expect(page.locator('#history-empty')).to_be_visible()
page.locator('#history-search').fill('')
page.get_by_role('button', name='IP addresses 2').click()
expect(page.locator('#route-count')).to_have_text('2')
value_column = page.locator('.tabulator-col[tabulator-field="value"]')
value_filter = value_column.locator('.tabulator-header-filter input')
dns_filter = page.locator('.tabulator-col[tabulator-field="dns_status"] .tabulator-header-filter input')
expect(value_filter).to_have_attribute('placeholder', 'Filter values')
expect(dns_filter).to_have_attribute('placeholder', 'Filter DNS')
value_filter.press_sequentially('198.51')
expect(page.locator('.tabulator-row:visible')).to_have_count(1)
expect(page.locator('.tabulator-row:visible')).to_contain_text('198.51.100.10')
value_filter.press('ControlOrMeta+a')
value_filter.press('Backspace')
expect(page.locator('.tabulator-row:visible')).to_have_count(2)
dns_filter.press_sequentially('not captured')
expect(page.locator('.tabulator-row:visible')).to_have_count(2)
dns_filter.press('ControlOrMeta+a')
dns_filter.press('Backspace')
expect(page.locator('.tabulator-row:visible')).to_have_count(2)
resize_handles = page.locator('.tabulator-header .tabulator-col-resize-handle')
expect(resize_handles).to_have_count(2)
selection_column_width = page.locator('.tabulator-header .tabulator-row-header').bounding_box()['width']
assert 40 <= selection_column_width <= 56
resize_handle = resize_handles.first
initial_width = value_column.bounding_box()['width']
handle_box = resize_handle.bounding_box()
page.mouse.move(handle_box['x'] + handle_box['width'] / 2, handle_box['y'] + handle_box['height'] / 2)
page.mouse.down()
page.mouse.move(handle_box['x'] + handle_box['width'] / 2 + 80, handle_box['y'] + handle_box['height'] / 2)
page.mouse.up()
assert value_column.bounding_box()['width'] >= initial_width + 60
page.locator('.tabulator-row').first.click()
expect(page.locator('#copy-route-button')).to_have_text('Copy selected (1)')
page.locator('#copy-route-button').click()
expect(page.locator('#toast')).to_have_text('Copied 1 selected IP addresses.')
assert page.evaluate('navigator.clipboard.readText()') == '192.0.2.10'
with page.expect_download() as jsonl_download:
page.get_by_role('button', name='All JSONL').click()
exported_records = [json.loads(line) for line in Path(jsonl_download.value.path()).read_text(encoding='utf-8').splitlines()]
assert exported_records[0]['type'] == 'summary'
assert exported_records[0]['evidence_status'] == 'partial'
assert any(record['type'] == 'ip' and record['value'] == '192.0.2.10' for record in exported_records[1:])
page.get_by_role('button', name='Start enumeration').first.click()
expect(page.locator('#new-run-dialog').get_by_text('Credentials required:', exact=False).first).to_be_visible()
page.get_by_role('button', name='Select all P0').click()
assert page.locator('[data-activity="P0"] input:checked').count() == len(passive_sources)
assert page.locator('[data-activity="P1"] input:checked, [data-activity="P2"] input:checked').count() == 0
assert page.locator('.action-grid input:checked').count() == 0
page.get_by_role('button', name='Clear', exact=True).click()
assert page.locator('[data-activity="P0"] input:checked').count() == 0
expect(page.locator('#activity-summary')).to_have_text('P0 off · P1 off · P2 off')
page.keyboard.press('Escape')
expect(page.get_by_role('button', name='Start enumeration').first).to_be_focused()
page.set_viewport_size({'width': 390, 'height': 844})
expect(page.locator('#provider-outcome-summary')).to_be_visible()
expect(page.get_by_role('columnheader', name='Outcome')).to_be_visible()
expect(page.get_by_role('columnheader', name='Results')).to_be_hidden()
expect(page.locator('#route-overflow-cue')).to_be_visible()
value_filter.scroll_into_view_if_needed()
expect(value_filter).to_be_visible()
assert value_filter.bounding_box()['height'] >= 44
assert page.locator('#route-tabs').evaluate('node => node.scrollWidth > node.clientWidth')
-1
View File
@@ -81,7 +81,6 @@ def test_api_exposes_one_fresh_run_contract(tmp_path, monkeypatch) -> None:
schema = client.get('/openapi.json').json()
paths = set(schema['paths'])
old_responses = [
client.get('/'),
client.get('/query?domain=example.test&source=crtsh'),
client.get('/sources'),
client.get('/dnsbrute?domain=example.test'),
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
import ipaddress
from fastapi.testclient import TestClient
def test_harvestview_owns_root_and_issues_an_http_only_session(tmp_path, monkeypatch) -> None:
from theHarvester.lib.api import api
from theHarvester.lib.resolver_selection import DEFAULT_DNS_RESOLVERS
monkeypatch.setenv('THEHARVESTER_API_KEY', 'test-key')
monkeypatch.setenv('THEHARVESTER_RUN_DB', str(tmp_path / 'runs.sqlite'))
monkeypatch.setenv('THEHARVESTER_RUN_WORKER', 'disabled')
with TestClient(api.app, base_url='http://127.0.0.1', client=('127.0.0.1', 50000)) as client:
root = client.get('/')
legacy = client.get('/app')
runs = client.get('/api/v1/runs')
assert root.status_code == 200
assert '<title>HarvestView</title>' in root.text
assert f'value="{",".join(DEFAULT_DNS_RESOLVERS)}"' in root.text
assert 'Resolve with the configured resolver addresses.' in root.text
assert legacy.status_code == 404
cookie = root.headers['set-cookie']
assert 'theharvester-api-key=' in cookie
assert 'test-key' not in cookie
assert 'HttpOnly' in cookie
assert 'SameSite=strict' in cookie
assert 'Path=/api/v1' in cookie
assert runs.status_code == 200
assert runs.json() == []
def test_harvestview_assets_load_outside_the_repository_directory(tmp_path, monkeypatch) -> None:
from theHarvester.lib.api import api
monkeypatch.setenv('THEHARVESTER_API_KEY', 'test-key')
monkeypatch.setenv('THEHARVESTER_RUN_DB', str(tmp_path / 'runs.sqlite'))
monkeypatch.setenv('THEHARVESTER_RUN_WORKER', 'disabled')
monkeypatch.chdir(tmp_path)
with TestClient(api.app, base_url='http://127.0.0.1', client=('127.0.0.1', 50000)) as client:
response = client.get('/static/harvestview/app.js')
assert response.status_code == 200
assert 'function renderResults' in response.text
def test_harvestview_offers_jsonl_and_sqlite_imports_with_jsonl_export(tmp_path, monkeypatch) -> None:
from theHarvester.lib.api import api
monkeypatch.setenv('THEHARVESTER_API_KEY', 'test-key')
monkeypatch.setenv('THEHARVESTER_RUN_DB', str(tmp_path / 'runs.sqlite'))
monkeypatch.setenv('THEHARVESTER_RUN_WORKER', 'disabled')
with TestClient(api.app, base_url='http://127.0.0.1', client=('127.0.0.1', 50000)) as client:
root = client.get('/')
script = client.get('/static/harvestview/app.js')
assert 'accept=".jsonl,.sqlite,.sqlite3,.db,application/x-ndjson,application/vnd.sqlite3"' in root.text
assert 'id="export-jsonl-button"' in root.text
assert 'id="route-csv-button"' not in root.text
assert 'id="export-json-button"' not in root.text
assert 'id="export-csv-button"' not in root.text
assert '/export' in script.text
assert "fileKind === 'jsonl' ? '/api/v1/runs/import' : '/api/v1/runs/import-database'" in script.text
assert '/exports/' not in script.text
assert 'text/csv' not in script.text
assert 'versioned JSONL' not in root.text
assert 'interesting-url' not in script.text
assert 'api-endpoint' not in script.text
assert 'linkedin-link' not in script.text
def test_harvestview_loads_pinned_tabulator_from_cdnjs(tmp_path, monkeypatch) -> None:
from theHarvester.lib.api import api
monkeypatch.setenv('THEHARVESTER_API_KEY', 'test-key')
monkeypatch.setenv('THEHARVESTER_RUN_DB', str(tmp_path / 'runs.sqlite'))
monkeypatch.setenv('THEHARVESTER_RUN_WORKER', 'disabled')
with TestClient(api.app, base_url='http://127.0.0.1', client=('127.0.0.1', 50000)) as client:
root = client.get('/')
theme = client.get('/static/harvestview/tabulator.min.css')
script = client.get('/static/harvestview/tabulator.min.js')
license_file = client.get('/static/harvestview/TABULATOR-LICENSE')
bootstrap = client.get('/static/harvestview/bootstrap.min.css')
old_theme = client.get('/static/harvestview/tabulator_bootstrap5.min.css')
assert root.status_code == 200
assert 'bootstrap.min.css' not in root.text
assert 'tabulator_bootstrap5.min.css' not in root.text
assert (
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/css/tabulator.min.css" '
'integrity="sha512-t8I/asqzdu/MRgVLxVanQ/c5bhUA1qZ/zA432a/3nUh0kkd7P8Qch35wQvTODivf9D6Xv3h7F8p7ezcUyBOQrQ==" '
'crossorigin="anonymous" referrerpolicy="no-referrer">'
) in root.text
assert (
'<script src="https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/js/tabulator.min.js" '
'integrity="sha512-AF0YMSgc0Ui4IJPb4hJNSi16wFidZEQa6ZTCAeguF3h5glVnAPuz/JT2ai9ypKhsc9n6CEXBB+tMdxsv1q+rxg==" '
'crossorigin="anonymous" referrerpolicy="no-referrer"></script>'
) in root.text
assert 'https://unpkg.com' not in root.text
assert theme.status_code == 404
assert script.status_code == 404
assert license_file.status_code == 404
assert bootstrap.status_code == 404
assert old_theme.status_code == 404
def test_docker_mode_trusts_only_the_detected_gateway(tmp_path, monkeypatch) -> None:
from theHarvester.lib.api import api, harvestview
monkeypatch.setenv('THEHARVESTER_API_KEY', 'test-key')
monkeypatch.setenv('THEHARVESTER_RUN_DB', str(tmp_path / 'runs.sqlite'))
monkeypatch.setenv('THEHARVESTER_RUN_WORKER', 'disabled')
monkeypatch.setattr(harvestview, '_docker_gateway', lambda: ipaddress.ip_address('172.18.0.1'))
with TestClient(api.app, base_url='http://127.0.0.1', client=('172.18.0.1', 50000)) as client:
disabled = client.get('/')
monkeypatch.setenv('THEHARVESTER_HARVESTVIEW_LOCAL_PROXY', 'enabled')
with TestClient(api.app, base_url='http://127.0.0.1', client=('172.18.0.1', 50000)) as client:
gateway = client.get('/')
with TestClient(api.app, base_url='http://127.0.0.1', client=('172.18.0.2', 50000)) as client:
sibling = client.get('/')
with TestClient(api.app, base_url='http://attacker.example', client=('172.18.0.1', 50000)) as client:
rebound = client.get('/')
assert disabled.status_code == 403
assert gateway.status_code == 200
assert sibling.status_code == 403
assert rebound.status_code == 403
+13
View File
@@ -8,6 +8,7 @@ import yaml
WORKFLOW_DIR = Path(__file__).parents[1] / '.github' / 'workflows'
CI_WORKFLOW_PATH = WORKFLOW_DIR / 'theHarvester.yml'
SMOKE_WORKFLOW_PATH = WORKFLOW_DIR / 'provider-smoke.yml'
HARVESTVIEW_WORKFLOW_PATH = WORKFLOW_DIR / 'harvestview-e2e.yml'
def _workflow(path: Path) -> dict[str, Any]:
@@ -35,3 +36,15 @@ def test_live_provider_smoke_requires_manual_dispatch() -> None:
assert workflow['permissions'] == {'contents': 'read'}
assert smoke_job['env']['SMOKE_TEST_DOMAIN'] == 'mozilla.org'
assert 'pytest --run-live-network -m live_network' in commands
def test_harvestview_browser_failures_keep_only_targeted_diagnostics() -> None:
workflow = _workflow(HARVESTVIEW_WORKFLOW_PATH)
steps = workflow['jobs']['harvestview-e2e']['steps']
test_command = next(step['run'] for step in steps if step['name'] == 'Run HarvestView browser tests')
upload = next(step for step in steps if step['name'] == 'Upload browser test artifacts')
assert '--video' not in test_command
assert '--tracing=retain-on-failure' in test_command
assert '--screenshot=only-on-failure' in test_command
assert upload['if'] == '${{ failure() }}'
+2
View File
@@ -8,6 +8,7 @@ from fastapi import FastAPI
from starlette.staticfiles import StaticFiles
from theHarvester import __version__
from theHarvester.lib.api.harvestview import router as harvestview_router
from theHarvester.lib.api.run_worker import start_worker, stop_worker
from theHarvester.lib.api.runs import router as api_router
from theHarvester.lib.database import dispose_sqlite_databases
@@ -36,6 +37,7 @@ app = FastAPI(
redoc_url='/redoc',
lifespan=lifespan,
)
app.include_router(harvestview_router)
app.include_router(api_router)
STATIC_DIRECTORY = Path(__file__).resolve().parent / 'static'
app.mount('/static', StaticFiles(directory=STATIC_DIRECTORY), name='static')
+21 -2
View File
@@ -1,12 +1,15 @@
import hmac
import os
import secrets
from pathlib import Path
from typing import Annotated
from fastapi import Header, HTTPException, status
from fastapi import Cookie, Header, HTTPException, Request, status
API_KEY_ENV_VAR = 'THEHARVESTER_API_KEY'
API_KEY_FILE_ENV_VAR = f'{API_KEY_ENV_VAR}_FILE'
API_KEY_COOKIE_NAME = 'theharvester-api-key'
BROWSER_SESSION_CONTEXT = b'theharvester-browser-session'
def _configured_api_key() -> str | None:
@@ -22,8 +25,14 @@ def _configured_api_key() -> str | None:
return None
def browser_session_token(configured_api_key: str) -> str:
return hmac.digest(configured_api_key.encode(), BROWSER_SESSION_CONTEXT, 'sha256').hex()
def get_api_key(
request: Request,
x_api_key: Annotated[str | None, Header(alias='X-API-Key')] = None,
api_key_cookie: Annotated[str | None, Cookie(alias=API_KEY_COOKIE_NAME)] = None,
) -> str:
"""Validate the API key used by protected API routes."""
configured_api_key = _configured_api_key()
@@ -33,10 +42,20 @@ def get_api_key(
detail=f'{API_KEY_ENV_VAR} is not configured',
)
if x_api_key is None or not secrets.compare_digest(x_api_key, configured_api_key):
valid_header = x_api_key is not None and secrets.compare_digest(x_api_key, configured_api_key)
expected_cookie = browser_session_token(configured_api_key)
valid_cookie = api_key_cookie is not None and secrets.compare_digest(api_key_cookie, expected_cookie)
if not valid_header and not valid_cookie:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid API key',
)
if valid_cookie and not valid_header and request.method not in {'GET', 'HEAD', 'OPTIONS'}:
expected_origin = str(request.base_url).rstrip('/')
if not secrets.compare_digest(request.headers.get('origin', ''), expected_origin):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Invalid request origin',
)
return configured_api_key
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import ipaddress
import os
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from theHarvester import __version__
from theHarvester.lib.api.auth import API_KEY_COOKIE_NAME, _configured_api_key, browser_session_token
from theHarvester.lib.resolver_selection import DEFAULT_DNS_RESOLVERS
router = APIRouter()
def _docker_gateway() -> ipaddress.IPv4Address | None:
try:
for line in Path('/proc/net/route').read_text(encoding='ascii').splitlines()[1:]:
fields = line.split()
if len(fields) >= 3 and fields[1] == '00000000':
return ipaddress.IPv4Address(bytes.fromhex(fields[2])[::-1])
except (OSError, ValueError):
pass
return None
@router.get('/', include_in_schema=False)
async def harvestview_app(request: Request) -> HTMLResponse:
try:
client_address = ipaddress.ip_address(request.client.host) if request.client is not None else None
trusted_gateway = (
_docker_gateway() if os.getenv('THEHARVESTER_HARVESTVIEW_LOCAL_PROXY', '').casefold() == 'enabled' else None
)
is_local_client = client_address is not None and (client_address.is_loopback or client_address == trusted_gateway)
except ValueError:
is_local_client = False
try:
hostname = request.url.hostname
is_loopback_host = hostname == 'localhost' or (hostname is not None and ipaddress.ip_address(hostname).is_loopback)
except ValueError:
is_loopback_host = False
if not is_local_client or not is_loopback_host:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='theHarvester is available only on localhost')
static_dir = Path(__file__).parent / 'static' / 'harvestview'
template = (static_dir / 'index.html').read_text(encoding='utf-8')
asset_version = max((static_dir / name).stat().st_mtime_ns for name in ('app.css', 'app.js'))
response = HTMLResponse(
template.replace('{{VERSION}}', __version__)
.replace('{{ASSET_VERSION}}', str(asset_version))
.replace('{{DNS_RESOLVERS}}', ','.join(DEFAULT_DNS_RESOLVERS))
)
if configured_api_key := _configured_api_key():
response.set_cookie(
API_KEY_COOKIE_NAME,
browser_session_token(configured_api_key),
httponly=True,
samesite='strict',
path='/api/v1',
)
return response
@@ -0,0 +1,773 @@
:root {
color-scheme: light;
--bg: oklch(0.955 0.012 78);
--surface: oklch(0.985 0.008 78);
--surface-raised: oklch(1 0 0);
--surface-muted: oklch(0.93 0.015 78);
--ink: oklch(0.22 0.022 220);
--ink-soft: oklch(0.38 0.025 220);
--muted: oklch(0.47 0.02 220);
--line: oklch(0.82 0.018 80);
--line-strong: oklch(0.68 0.025 220);
--nav: oklch(0.205 0.028 220);
--nav-raised: oklch(0.245 0.03 220);
--nav-line: oklch(0.37 0.028 220);
--nav-ink: oklch(0.95 0.012 82);
--nav-muted: oklch(0.75 0.023 210);
--accent: oklch(0.6 0.115 182);
--accent-strong: oklch(0.4 0.105 182);
--accent-text: oklch(0.4 0.105 182);
--accent-soft: oklch(0.91 0.052 182);
--focus: oklch(0.58 0.14 182);
--info: oklch(0.43 0.12 245);
--info-soft: oklch(0.92 0.04 245);
--warning: oklch(0.42 0.12 74);
--warning-soft: oklch(0.93 0.06 80);
--danger: oklch(0.47 0.18 28);
--danger-soft: oklch(0.93 0.045 28);
--success: oklch(0.4 0.12 155);
--success-soft: oklch(0.92 0.045 155);
--shadow: 0 18px 60px oklch(0.18 0.02 220 / 0.16);
--font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--z-sticky: 20;
--z-modal: 50;
--z-toast: 70;
--z-tooltip: 80;
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: oklch(0.17 0.018 220);
--surface: oklch(0.205 0.02 220);
--surface-raised: oklch(0.245 0.022 220);
--surface-muted: oklch(0.275 0.022 220);
--ink: oklch(0.94 0.012 80);
--ink-soft: oklch(0.81 0.015 80);
--muted: oklch(0.7 0.02 210);
--line: oklch(0.36 0.024 220);
--line-strong: oklch(0.49 0.03 210);
--nav: oklch(0.135 0.023 220);
--nav-raised: oklch(0.18 0.026 220);
--nav-line: oklch(0.32 0.027 220);
--nav-ink: oklch(0.95 0.012 82);
--nav-muted: oklch(0.72 0.023 210);
--accent: oklch(0.72 0.12 182);
--accent-strong: oklch(0.4 0.105 182);
--accent-text: oklch(0.82 0.1 182);
--accent-soft: oklch(0.28 0.055 182);
--focus: oklch(0.78 0.13 182);
--info: oklch(0.72 0.12 245);
--info-soft: oklch(0.28 0.055 245);
--warning: oklch(0.78 0.13 78);
--warning-soft: oklch(0.3 0.06 78);
--danger: oklch(0.73 0.16 28);
--danger-soft: oklch(0.29 0.065 28);
--success: oklch(0.72 0.13 155);
--success-soft: oklch(0.28 0.055 155);
--shadow: 0 22px 72px oklch(0.05 0.02 220 / 0.42);
}
@media (prefers-color-scheme: dark) {
html[data-theme="system"] {
color-scheme: dark;
--bg: oklch(0.17 0.018 220);
--surface: oklch(0.205 0.02 220);
--surface-raised: oklch(0.245 0.022 220);
--surface-muted: oklch(0.275 0.022 220);
--ink: oklch(0.94 0.012 80);
--ink-soft: oklch(0.81 0.015 80);
--muted: oklch(0.7 0.02 210);
--line: oklch(0.36 0.024 220);
--line-strong: oklch(0.49 0.03 210);
--nav: oklch(0.135 0.023 220);
--nav-raised: oklch(0.18 0.026 220);
--nav-line: oklch(0.32 0.027 220);
--accent: oklch(0.72 0.12 182);
--accent-strong: oklch(0.4 0.105 182);
--accent-text: oklch(0.82 0.1 182);
--accent-soft: oklch(0.28 0.055 182);
--focus: oklch(0.78 0.13 182);
--info: oklch(0.72 0.12 245);
--info-soft: oklch(0.28 0.055 245);
--warning: oklch(0.78 0.13 78);
--warning-soft: oklch(0.3 0.06 78);
--danger: oklch(0.73 0.16 28);
--danger-soft: oklch(0.29 0.065 28);
--success: oklch(0.72 0.13 155);
--success-soft: oklch(0.28 0.055 155);
--shadow: 0 22px 72px oklch(0.05 0.02 220 / 0.42);
}
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--nav); }
h1,
h2,
h3,
p { margin-block-start: 0; }
fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
table { border-collapse: collapse; }
img {
max-width: 100%;
vertical-align: middle;
}
body {
min-height: 100vh;
margin: 0;
color: var(--ink);
background: var(--bg);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.5;
text-rendering: optimizeLegibility;
}
button,
input,
select,
textarea { font: inherit; }
button { touch-action: manipulation; }
button:focus-visible,
input:focus-visible,
select:focus-visible,
summary:focus-visible,
a:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
[hidden] { display: none !important; }
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.skip-link {
position: fixed;
z-index: calc(var(--z-toast) + 1);
inset: 12px auto auto 12px;
padding: 10px 14px;
color: var(--nav);
background: var(--accent);
border-radius: 6px;
transform: translateY(-160%);
}
.skip-link:focus { transform: translateY(0); }
.mono { font-family: var(--font-mono); }
.eyebrow {
margin: 0 0 5px;
color: var(--muted);
font: 700 11px/1.2 var(--font-mono);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.app-header {
position: sticky;
z-index: var(--z-sticky);
inset-block-start: 0;
display: flex;
min-height: 72px;
align-items: center;
gap: 28px;
padding: 10px 22px;
color: var(--nav-ink);
background: var(--nav);
border-block-end: 1px solid var(--nav-line);
}
.brand {
display: inline-flex;
min-width: 265px;
align-items: center;
gap: 12px;
color: inherit;
text-decoration: none;
}
.brand img { width: 44px; height: 44px; object-fit: contain; }
.brand-copy { display: grid; gap: 1px; }
.brand strong { font-size: 17px; line-height: 1.1; letter-spacing: -0.015em; }
.brand small { color: var(--nav-muted); font: 11px/1.3 var(--font-mono); }
.version-badge {
padding: 4px 7px;
color: var(--nav-muted);
border: 1px solid var(--nav-line);
border-radius: 999px;
font: 700 10px/1 var(--font-mono);
white-space: nowrap;
}
.header-context {
display: flex;
align-items: center;
gap: 9px;
color: var(--nav-muted);
font-size: 12px;
}
.signal-dot {
width: 8px;
height: 8px;
background: var(--accent);
border-radius: 50%;
box-shadow: 0 0 0 4px oklch(0.72 0.12 182 / 0.14);
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-inline-start: auto;
}
.header-button,
.icon-button,
.button {
min-height: 44px;
padding: 8px 13px;
color: var(--ink);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: 7px;
font-weight: 700;
transition: border-color 140ms ease-out, background-color 140ms ease-out, color 140ms ease-out, transform 140ms ease-out;
}
.header-button {
color: var(--nav-ink);
background: var(--nav-raised);
border-color: var(--nav-line);
font-size: 13px;
}
.header-button:hover,
.icon-button:hover { border-color: var(--accent); }
.header-button.primary,
.button.primary { color: oklch(0.15 0.025 220); background: var(--accent); border-color: var(--accent); }
.header-button.primary:hover,
.button.primary:hover { background: var(--accent-strong); color: white; }
.button:hover { border-color: var(--accent-strong); }
.button:active,
.header-button:active { transform: translateY(1px); }
.button.small { min-height: 44px; padding: 8px 10px; font-size: 12px; }
.button.danger { color: var(--danger); background: var(--danger-soft); border-color: var(--danger); }
.button:disabled { cursor: wait; opacity: 0.58; }
.icon-button {
display: inline-grid;
width: 44px;
padding: 0;
place-items: center;
color: var(--nav-ink);
background: var(--nav-raised);
border-color: var(--nav-line);
font-size: 22px;
}
.app-shell {
display: grid;
min-height: calc(100vh - 72px);
grid-template-columns: minmax(285px, 335px) minmax(0, 1fr);
}
.history-panel {
position: sticky;
inset-block-start: 72px;
min-width: 0;
height: calc(100vh - 72px);
padding: 24px 17px 36px;
overflow-y: auto;
color: var(--nav-ink);
background: var(--nav);
border-inline-end: 1px solid var(--nav-line);
}
.history-panel .eyebrow { color: var(--nav-muted); }
.history-heading { display: flex; align-items: end; justify-content: space-between; gap: 14px; margin: 0 5px 17px; }
.history-heading h1 { margin: 0; font-size: 20px; line-height: 1.15; letter-spacing: -0.025em; }
.count-badge {
display: inline-flex;
min-width: 28px;
min-height: 24px;
align-items: center;
justify-content: center;
padding: 3px 8px;
color: var(--nav);
background: var(--accent);
border-radius: 999px;
font: 700 11px/1 var(--font-mono);
}
.count-badge.quiet { color: var(--ink); background: var(--surface-muted); }
.search-field {
display: flex;
min-height: 46px;
align-items: center;
gap: 8px;
padding: 0 11px;
color: var(--nav-muted);
background: var(--nav-raised);
border: 1px solid var(--nav-line);
border-radius: 8px;
}
.search-field:focus-within { border-color: var(--accent); }
.search-field input { width: 100%; min-width: 0; min-height: 44px; color: var(--nav-ink); background: transparent; border: 0; outline: 0; }
.search-field input::placeholder { color: var(--nav-muted); opacity: 1; }
.run-list { display: grid; gap: 7px; margin-block-start: 14px; }
.run-item {
position: relative;
display: grid;
width: 100%;
min-height: 78px;
grid-template-columns: minmax(0, 1fr) auto;
gap: 5px 10px;
padding: 13px 12px 12px 16px;
overflow: hidden;
color: var(--nav-ink);
text-align: start;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
}
.run-item::before { content: ""; position: absolute; inset: 10px auto 10px 0; width: 3px; background: var(--nav-line); border-radius: 0 3px 3px 0; }
.run-item:hover { background: var(--nav-raised); border-color: var(--nav-line); }
.run-item.selected { background: var(--nav-raised); border-color: var(--accent); }
.run-item.selected::before { background: var(--accent); }
.run-target { min-width: 0; overflow: hidden; font-weight: 760; text-overflow: ellipsis; white-space: nowrap; }
.run-meta { min-width: 0; overflow: hidden; color: var(--nav-muted); font: 11px/1.4 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.run-results { align-self: end; color: var(--nav-muted); font: 11px/1 var(--font-mono); }
.history-empty { padding: 42px 10px; color: var(--nav-muted); text-align: center; }
.history-empty span { display: block; font-size: 26px; }
.workbench { min-width: 0; padding: clamp(22px, 3.2vw, 48px); background: var(--bg); }
.center-state {
display: grid;
min-height: min(620px, calc(100vh - 150px));
max-width: 690px;
align-content: center;
justify-items: center;
margin: auto;
text-align: center;
}
.center-state h2 { max-width: 15ch; margin: 8px 0 9px; font-size: clamp(28px, 4vw, 46px); line-height: 1.02; letter-spacing: -0.035em; text-wrap: balance; }
.center-state > p:not(.eyebrow) { max-width: 62ch; color: var(--ink-soft); text-wrap: pretty; }
.loader { width: 34px; height: 34px; border: 3px solid var(--line); border-block-start-color: var(--accent); border-radius: 50%; animation: spin 700ms linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty-mark { display: grid; width: 74px; height: 74px; margin-block-end: 20px; place-items: center; color: var(--accent-text); border: 1px solid var(--accent); border-radius: 50%; font: 800 30px/1 var(--font-mono); }
.empty-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 9px; margin-block: 18px 32px; }
.empty-guardrails { display: grid; width: 100%; grid-template-columns: repeat(3, 1fr); margin: 0; border-block: 1px solid var(--line); }
.empty-guardrails div { padding: 16px; text-align: start; border-inline-end: 1px solid var(--line); }
.empty-guardrails div:last-child { border: 0; }
.empty-guardrails dt { color: var(--accent-text); font: 800 12px/1 var(--font-mono); }
.empty-guardrails dd { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
.run-detail { width: min(1500px, 100%); margin-inline: auto; }
.detail-header { display: flex; align-items: start; justify-content: space-between; gap: 24px; padding-block-end: 25px; border-block-end: 1px solid var(--line-strong); }
.detail-identity { min-width: 0; }
.detail-identity h2 { margin: 2px 0 5px; overflow-wrap: anywhere; font-size: clamp(31px, 4vw, 58px); line-height: 1; letter-spacing: -0.038em; text-wrap: balance; }
.detail-run-id { margin: 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; }
.detail-status { display: flex; flex: 0 0 auto; flex-direction: column; align-items: end; gap: 12px; }
.status-chips { display: flex; flex-wrap: wrap; justify-content: end; gap: 6px; }
.provider-outcome-summary { margin: -4px 0 0; color: var(--muted); font: 11px/1.4 var(--font-mono); }
.status-chip {
display: inline-flex;
min-height: 28px;
align-items: center;
gap: 6px;
padding: 4px 9px;
color: var(--ink-soft);
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 999px;
font: 750 11px/1 var(--font-mono);
text-transform: capitalize;
}
.status-chip::before { content: ""; width: 7px; height: 7px; background: currentColor; border-radius: 50%; }
.status-chip.completed, .status-chip.complete, .status-chip.succeeded { color: var(--success); background: var(--success-soft); }
.status-chip.running { color: var(--info); background: var(--info-soft); }
.status-chip.queued, .status-chip.cancelling, .status-chip.partial, .status-chip.rate-limited { color: var(--warning); background: var(--warning-soft); }
.status-chip.failed, .status-chip.cancelled { color: var(--danger); background: var(--danger-soft); }
.run-facts { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); margin: 0; border-block-end: 1px solid var(--line); }
.run-facts div { min-width: 0; padding: 17px 15px; border-inline-end: 1px solid var(--line); }
.run-facts div:first-child { padding-inline-start: 0; }
.run-facts div:last-child { border: 0; }
.run-facts dt { color: var(--muted); font: 700 10px/1.2 var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; }
.run-facts dd { margin: 6px 0 0; overflow-wrap: anywhere; font-weight: 720; }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-block-end: 16px; }
.section-heading.compact { align-items: start; margin-block-end: 13px; }
.section-heading h3 { margin: 0; font-size: 22px; line-height: 1.1; letter-spacing: -0.025em; }
.heading-with-help { display: flex; align-items: center; gap: 7px; }
.help-tip {
position: relative;
display: inline-grid;
width: 45px;
height: 45px;
flex: 0 0 45px;
padding: 0;
place-items: center;
color: var(--accent-text);
background: var(--surface-muted);
border: 1px solid var(--line-strong);
border-radius: 50%;
font: 800 12px/1 var(--font-mono);
}
.help-tip:hover, .help-tip:focus { color: var(--nav); background: var(--accent); border-color: var(--accent); }
.help-tip::after {
position: absolute;
z-index: var(--z-tooltip);
inset: calc(100% + 8px) auto auto 0;
width: min(300px, calc(100vw - 40px));
padding: 9px 11px;
visibility: hidden;
color: var(--nav-ink);
text-align: start;
content: attr(data-tooltip);
background: var(--nav);
border: 1px solid var(--nav-line);
border-radius: 7px;
box-shadow: 0 4px 8px oklch(0.08 0.02 220 / 0.22);
opacity: 0;
font: 12px/1.45 var(--font-sans);
pointer-events: none;
transform: translateY(-4px);
transition: opacity 150ms ease, transform 150ms ease, visibility 150ms;
}
.help-tip:hover::after, .help-tip:focus::after { visibility: visible; opacity: 1; transform: translateY(0); }
.section-note { max-width: 65ch; margin: 4px 0 0; color: var(--muted); font-size: 12px; text-wrap: pretty; }
.section-actions { display: flex; flex-wrap: wrap; justify-content: end; gap: 6px; }
.lifecycle-section { padding-block: 24px 20px; }
.lifecycle-track { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); margin: 0; padding: 0; list-style: none; }
.lifecycle-step { position: relative; min-width: 0; padding: 20px 14px 0 0; border-block-start: 2px solid var(--line); }
.lifecycle-step::before { content: ""; position: absolute; inset: -6px auto auto 0; width: 10px; height: 10px; background: var(--surface-raised); border: 2px solid var(--line-strong); border-radius: 50%; }
.lifecycle-step.reached { border-color: var(--accent); }
.lifecycle-step.reached::before { background: var(--accent); border-color: var(--accent); }
.lifecycle-step strong { display: block; overflow: hidden; font: 750 11px/1.3 var(--font-mono); text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
.lifecycle-step span { display: block; margin-block-start: 5px; color: var(--muted); font-size: 11px; }
.overview-grid { display: grid; grid-template-columns: 1fr; border: 1px solid var(--line-strong); }
.overview-panel { min-width: 0; padding: 20px; background: var(--surface); }
.activity-bands { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-block-end: 18px; }
.activity-band { min-width: 0; padding: 10px; color: var(--muted); background: var(--surface-muted); border-block-start: 3px solid var(--line-strong); }
.activity-band.active.p0 { color: var(--accent-text); border-color: var(--accent); background: var(--accent-soft); }
.activity-band.active.p1 { color: var(--warning); border-color: var(--warning); background: var(--warning-soft); }
.activity-band.active.p2 { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); }
.activity-band strong { display: block; font: 800 11px/1 var(--font-mono); }
.activity-band span { display: block; margin-block-start: 5px; font-size: 11px; }
.request-options { display: grid; grid-template-columns: 1fr 1fr; margin: 0; }
.request-options div { min-width: 0; padding: 8px 10px 8px 0; border-block-start: 1px solid var(--line); }
.request-options dt { color: var(--muted); font-size: 11px; }
.request-options dd { margin: 2px 0 0; overflow-wrap: anywhere; font: 12px/1.4 var(--font-mono); }
.provider-table-wrap { max-height: 264px; overflow: auto; }
.provider-details { margin-block-start: 28px; padding: 0 20px 20px; background: var(--surface); border: 1px solid var(--line-strong); }
.provider-details summary { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; font-size: 18px; font-weight: 760; }
.provider-details summary::before { content: ""; color: var(--accent-text); font-size: 24px; }
.provider-details[open] summary::before { transform: rotate(90deg); }
.provider-details summary #provider-title { margin-inline-end: auto; }
.provider-table { width: 100%; border-collapse: collapse; }
.provider-table th, .provider-table td { padding: 9px 8px; text-align: start; border-block-end: 1px solid var(--line); }
.provider-table th { position: sticky; inset-block-start: 0; z-index: 1; color: var(--muted); background: var(--surface); font: 700 10px/1.2 var(--font-mono); letter-spacing: 0.06em; text-transform: uppercase; }
.provider-table td:first-child { font-weight: 720; }
.provider-table td:nth-child(4), .provider-table th:nth-child(4), .provider-table td:nth-child(5), .provider-table th:nth-child(5) { text-align: end; }
.panel-empty { margin: 25px 0; color: var(--muted); text-align: center; }
.results-section { margin-block-start: 36px; }
.results-heading { align-items: start; }
.route-tabs { display: flex; overflow-x: auto; border-block-end: 1px solid var(--line-strong); scrollbar-width: thin; }
.route-overflow-cue { display: none; margin: 7px 0 0; color: var(--muted); font-size: 12px; }
.route-tab { display: inline-flex; min-height: 46px; flex: 0 0 auto; align-items: center; gap: 8px; padding: 8px 13px; color: var(--muted); background: transparent; border: 0; border-block-end: 3px solid transparent; font-weight: 720; }
.route-tab:hover { color: var(--ink); background: var(--surface-muted); }
.route-tab.active { color: var(--accent-text); border-color: var(--accent); }
.route-tab .count-badge { min-width: 23px; min-height: 21px; color: var(--ink); background: var(--surface-muted); }
.result-workbench { margin-block-start: 14px; border: 1px solid var(--line-strong); background: var(--surface); }
.table-toolbar { display: flex; min-height: 60px; align-items: center; justify-content: space-between; gap: 16px; padding: 9px 12px; border-block-end: 1px solid var(--line); }
.table-toolbar label { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 12px; }
.table-toolbar input { width: min(340px, 42vw); min-height: 44px; padding: 7px 10px; color: var(--ink); background: var(--surface-raised); border: 1px solid var(--line); border-radius: 6px; }
.table-guidance { max-width: 34ch; margin: 0; color: var(--muted); font-size: 11px; line-height: 1.4; }
.results-empty { padding: 54px 20px; color: var(--muted); text-align: center; border-block-end: 1px solid var(--line); }
.results-empty span { font: 34px/1 var(--font-mono); }
.results-empty h4 { margin: 11px 0 4px; color: var(--ink); }
.results-empty p { max-width: 72ch; margin: 0 auto; }
.tabulator {
color: var(--ink);
background: var(--surface);
border: 0;
font-family: var(--font-sans);
font-size: 13px;
}
.tabulator .tabulator-header { color: var(--ink-soft); background: var(--surface-muted); border-color: var(--line); }
.tabulator .tabulator-header .tabulator-col { min-height: 44px; background: var(--surface-muted); border-color: var(--line); }
.tabulator .tabulator-header .tabulator-header-filter { margin-block-start: 7px; }
.tabulator .tabulator-header .tabulator-col .tabulator-header-filter input {
width: 100%;
min-height: 34px;
padding: 6px 8px;
color: var(--ink);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: 5px;
font: 12px/1.2 var(--font-sans);
}
.tabulator .tabulator-header .tabulator-col .tabulator-header-filter input::placeholder { color: var(--muted); opacity: 1; }
.tabulator .tabulator-header .tabulator-col .tabulator-header-filter input:focus-visible { color: var(--ink); background: var(--surface-raised); border-color: var(--accent); outline: 3px solid var(--focus); outline-offset: -2px; }
.tabulator .tabulator-header .tabulator-col-resize-handle { width: 10px; margin-inline: -5px; }
.tabulator .tabulator-header .tabulator-col-resize-handle::after {
position: absolute;
inset: 8px auto 8px 50%;
width: 1px;
content: "";
background: var(--line-strong);
}
.tabulator .tabulator-header .tabulator-col-resize-handle:hover::after { width: 2px; background: var(--accent-strong); }
.tabulator .tabulator-col-resize-guide { width: 2px; background: var(--accent-strong); opacity: 0.9; }
.tabulator-row { min-height: 45px; color: var(--ink); background: var(--surface); border-color: var(--line); }
.tabulator-row.tabulator-row-even { background: var(--surface-raised); }
.tabulator-row:hover { background: var(--accent-soft); }
.tabulator-row.tabulator-selected { color: var(--ink); background: var(--accent-soft); }
.tabulator-row .tabulator-cell { padding: 11px 10px; border-color: var(--line); }
.tabulator .tabulator-row .tabulator-cell.tabulator-row-header { background: inherit; }
.tabulator .tabulator-footer { color: var(--ink-soft); background: var(--surface-muted); border-color: var(--line); }
.tabulator .tabulator-footer .tabulator-page { min-width: 44px; min-height: 44px; color: var(--ink); background: var(--surface-raised); border-color: var(--line); }
.tabulator .tabulator-footer .tabulator-page-size { min-height: 44px; }
.tabulator .tabulator-footer .tabulator-page.active { color: var(--nav); background: var(--accent); }
.value-cell { overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 12px; }
.result-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.result-actions .button { flex: 1 1 118px; white-space: nowrap; }
.dns-label { display: inline-flex; min-width: 82px; align-items: center; gap: 6px; font-size: 11px; font-weight: 750; }
.dns-label::before { content: ""; width: 7px; height: 7px; background: currentColor; border-radius: 50%; }
.dns-label.resolved { color: var(--success); }
.dns-label.disputed, .dns-label.uncertain { color: var(--warning); }
.dns-label.no-answer { color: var(--danger); }
.dns-label.not-captured { color: var(--muted); }
.screenshot-section { margin-block-start: 38px; padding-block-start: 28px; border-block-start: 1px solid var(--line-strong); }
.screenshot-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
.screenshot-card { min-width: 0; padding: 0; overflow: hidden; color: var(--ink); text-align: start; background: var(--surface); border: 1px solid var(--line-strong); border-radius: 8px; }
.screenshot-card:hover { border-color: var(--accent); }
.screenshot-frame { display: grid; aspect-ratio: 16 / 10; overflow: hidden; place-items: center; color: var(--muted); background: var(--surface-muted); }
.screenshot-frame img { width: 100%; height: 100%; object-fit: cover; }
.screenshot-card strong { display: block; padding: 10px 12px 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.screenshot-card small { display: block; padding: 0 12px 11px; color: var(--muted); }
.run-log { margin-block-start: 28px; color: var(--ink-soft); border-block: 1px solid var(--line); }
.run-log summary { padding: 14px 4px; cursor: pointer; font-weight: 730; }
.run-log pre { max-height: 360px; margin: 0 0 15px; padding: 15px; overflow: auto; color: var(--ink-soft); background: var(--surface-muted); font: 11px/1.5 var(--font-mono); white-space: pre-wrap; }
dialog {
max-width: calc(100vw - 28px);
max-height: calc(100vh - 28px);
padding: 0;
overflow: auto;
color: var(--ink);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: 12px;
box-shadow: var(--shadow);
}
dialog::backdrop { background: oklch(0.08 0.02 220 / 0.72); backdrop-filter: blur(3px); }
dialog[open] { animation: dialog-in 180ms cubic-bezier(0.22, 1, 0.36, 1); }
@keyframes dialog-in { from { opacity: 0; transform: translateY(10px) scale(0.985); } }
.form-dialog { width: min(650px, calc(100vw - 28px)); }
.wide-dialog { width: min(980px, calc(100vw - 28px)); }
.dialog-content { padding: clamp(22px, 4vw, 34px); }
.dialog-header { display: flex; align-items: start; justify-content: space-between; gap: 15px; }
.dialog-content h2 { margin: 0; font-size: clamp(26px, 4vw, 36px); line-height: 1.05; letter-spacing: -0.032em; text-wrap: balance; }
.dialog-content > p { max-width: 70ch; color: var(--ink-soft); }
.dialog-content code { color: var(--accent-text); }
.dialog-content label:not(.action-choice, .source-choice, .file-drop) { display: grid; gap: 6px; font-weight: 720; }
.dialog-content input:not([type="checkbox"], [type="file"]), .dialog-content select, .dialog-content textarea { width: 100%; min-height: 44px; padding: 9px 11px; color: var(--ink); background: var(--surface); border: 1px solid var(--line-strong); border-radius: 7px; }
.dialog-content input::placeholder { color: var(--muted); opacity: 1; }
.dialog-content textarea { min-height: 88px; resize: vertical; }
.dialog-content small { color: var(--muted); font-weight: 450; }
.dialog-close { color: var(--ink); background: transparent; border-color: var(--line); }
.form-grid { display: grid; gap: 14px; margin-block: 23px; }
.form-grid.three { grid-template-columns: minmax(220px, 1.4fr) minmax(130px, 0.6fr) minmax(160px, 0.7fr); }
.form-grid.three .api-scan-paths { grid-column: 1 / -1; }
.advanced-execution { margin-block: 18px 0; padding: 12px 14px; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; }
.advanced-execution summary { min-height: 32px; align-content: center; font-weight: 780; cursor: pointer; }
.advanced-execution .form-grid { margin-block-end: 0; }
.dialog-content .inline-choice { display: flex; min-height: 44px; align-items: center; align-self: end; gap: 8px; }
.inline-choice input { width: 17px; height: 17px; accent-color: var(--accent-strong); }
.source-fieldset, .action-fieldset { margin: 24px 0 0; padding: 0; border: 0; }
.source-fieldset legend, .action-fieldset legend { margin-block-end: 10px; font-size: 16px; font-weight: 780; }
.source-tools { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-block-end: 10px; }
.source-search { max-width: 390px; margin-block-end: 10px; }
.source-tools .source-search { width: min(390px, 100%); margin-block-end: 0; }
.source-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: end; gap: 6px; }
.source-groups { display: grid; max-height: 285px; grid-template-columns: repeat(3, 1fr); gap: 10px; overflow: auto; }
.source-group { min-width: 0; padding: 10px; background: var(--surface); border-block-start: 3px solid var(--line-strong); }
.source-group[data-activity="P0"] { border-color: var(--accent); }
.source-group[data-activity="P1"] { border-color: var(--warning); }
.source-group[data-activity="P2"] { border-color: var(--danger); }
.source-group h3 { margin: 0 0 8px; font: 800 11px/1 var(--font-mono); }
.source-choice { display: flex; min-height: 44px; align-items: start; gap: 8px; padding: 9px 5px; border-block-start: 1px solid var(--line); font-weight: 650; }
.source-choice input, .action-choice input { flex: 0 0 auto; width: 17px; height: 17px; margin-block-start: 2px; accent-color: var(--accent-strong); }
.source-choice span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.source-choice small { display: block; overflow: hidden; font: 10px/1.35 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.source-choice .credential-note { color: var(--warning); }
.source-group-empty { color: var(--muted); font-size: 12px; }
.action-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
.action-choice { display: flex; min-width: 0; min-height: 76px; align-items: start; gap: 9px; padding: 10px; background: var(--surface); border: 1px solid var(--line); border-block-start-width: 3px; border-radius: 6px; }
.action-choice.p0 { border-block-start-color: var(--accent); }
.action-choice.p1 { border-block-start-color: var(--warning); }
.action-choice.p2 { border-block-start-color: var(--danger); }
.action-choice strong, .action-choice small { display: block; }
.action-choice strong { line-height: 1.3; }
.action-choice small { margin-block-start: 3px; font-size: 10px; line-height: 1.35; }
.activity-summary { padding: 11px 13px; color: var(--ink) !important; background: var(--accent-soft); border: 1px solid var(--accent); font: 700 12px/1.4 var(--font-mono); }
.form-error { margin: 12px 0 0; padding: 10px 12px; color: var(--danger) !important; background: var(--danger-soft); border: 1px solid var(--danger); font-weight: 650; }
.dialog-actions { display: flex; justify-content: end; gap: 8px; margin-block-start: 24px; }
#new-run-dialog .dialog-actions {
position: sticky;
z-index: var(--z-sticky);
inset-block-end: 0;
padding-block: 14px;
background: var(--surface-raised);
border-block-start: 1px solid var(--line);
}
.file-drop { position: relative; display: grid; min-height: 210px; margin-block-start: 22px; padding: 28px; place-items: center; align-content: center; text-align: center; background: var(--surface); border: 2px dashed var(--line-strong); border-radius: 9px; cursor: pointer; }
.file-drop:hover, .file-drop:focus-within { border-color: var(--accent); background: var(--accent-soft); }
.file-drop input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; }
.file-mark { margin-block-end: 9px; color: var(--accent-text); font: 34px/1 var(--font-mono); }
.file-drop strong, .file-drop small { display: block; }
.screenshot-dialog { width: min(1180px, calc(100vw - 28px)); }
.screenshot-dialog-header { display: flex; align-items: start; justify-content: space-between; gap: 20px; padding: 18px 20px; border-block-end: 1px solid var(--line); }
.screenshot-dialog-header h2 { margin: 0; overflow-wrap: anywhere; font-size: 24px; }
.screenshot-dialog img { display: block; width: 100%; max-height: calc(100vh - 150px); object-fit: contain; background: var(--surface-muted); }
.toast-message { position: fixed; z-index: var(--z-toast); inset: auto 22px 22px auto; max-width: min(430px, calc(100vw - 44px)); padding: 12px 15px; color: var(--nav-ink); background: var(--nav); border: 1px solid var(--accent); border-radius: 8px; box-shadow: var(--shadow); }
.toast-message.error { border-color: var(--danger); }
@media (max-width: 1080px) {
.header-context { display: none; }
.app-shell { grid-template-columns: 290px minmax(0, 1fr); }
.workbench { padding: 28px 24px; }
.run-facts { grid-template-columns: repeat(3, 1fr); }
.run-facts div:nth-child(3) { border: 0; }
.run-facts div:nth-child(n+4) { border-block-start: 1px solid var(--line); }
.overview-grid { grid-template-columns: 1fr; }
.form-grid.three { grid-template-columns: 1.4fr 0.6fr; }
.form-grid.three label:last-child { grid-column: 1 / -1; }
}
@media (max-width: 780px) {
body { font-size: 16px; }
.help-tip::after { position: fixed; inset: auto 16px 16px; width: auto; }
.app-header { position: relative; flex-wrap: wrap; min-height: auto; gap: 9px; padding: 10px 14px; }
.brand { min-width: 0; }
.brand small { display: none; }
.header-actions { width: 100%; flex-wrap: wrap; order: 2; }
.header-button { flex: 0 0 auto; }
.app-shell { display: block; }
.history-panel { position: relative; inset-block-start: auto; height: auto; padding: 18px 14px; overflow: visible; border-inline-end: 0; border-block-end: 1px solid var(--nav-line); }
.run-list { display: flex; padding-block-end: 4px; overflow-x: auto; }
.run-item { min-width: 235px; flex: 0 0 235px; }
.workbench { padding: 25px 16px 48px; }
.detail-header, .section-heading, .results-heading { align-items: start; flex-direction: column; }
.detail-status { width: 100%; align-items: start; }
.status-chips { justify-content: start; }
.route-overflow-cue { display: block; }
.run-facts { grid-template-columns: 1fr 1fr; }
.run-facts div, .run-facts div:nth-child(3), .run-facts div:nth-child(n+4) { padding: 12px 10px; border: 0; border-block-end: 1px solid var(--line); }
.run-facts div:nth-child(odd) { padding-inline-start: 0; border-inline-end: 1px solid var(--line); }
.lifecycle-track { grid-template-columns: 1fr; }
.lifecycle-step { min-height: 65px; padding: 0 0 18px 24px; border-block-start: 0; border-inline-start: 2px solid var(--line); }
.lifecycle-step::before { inset: 0 auto auto -6px; }
.activity-bands { grid-template-columns: 1fr; }
.request-options { grid-template-columns: 1fr; }
.source-groups { grid-template-columns: 1fr; }
.source-tools { align-items: stretch; flex-direction: column; }
.source-actions .button { flex: 1; }
.action-grid { grid-template-columns: 1fr 1fr; }
.form-grid.three { grid-template-columns: 1fr; }
.form-grid.three label:last-child { grid-column: auto; }
.section-actions { justify-content: start; }
.table-toolbar { align-items: stretch; flex-direction: column; }
.table-toolbar label { align-items: stretch; flex-direction: column; }
.table-toolbar input { width: 100%; }
.table-guidance { max-width: none; }
.tabulator .tabulator-header .tabulator-col .tabulator-header-filter input { min-height: 44px; }
.provider-table th:nth-child(5), .provider-table td:nth-child(5) { display: none; }
.empty-guardrails { grid-template-columns: 1fr; }
.empty-guardrails div { border-inline-end: 0; border-block-end: 1px solid var(--line); }
.empty-guardrails div:last-child { border-block-end: 0; }
}
@media (max-width: 500px) {
.brand img { width: 38px; height: 38px; }
.brand strong { font-size: 15px; }
.version-badge { padding: 3px 6px; font-size: 9px; }
.header-button { font-size: 12px; }
#theme-button { white-space: nowrap; }
.detail-identity h2 { font-size: 34px; }
.action-grid { grid-template-columns: 1fr; }
.dialog-content { padding: 20px 16px; }
.dialog-actions { align-items: stretch; flex-direction: column-reverse; }
.dialog-actions .button { width: 100%; }
.provider-table th:nth-child(4), .provider-table td:nth-child(4) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (forced-colors: active) {
.signal-dot, .status-chip::before, .dns-label::before, .lifecycle-step::before { forced-color-adjust: none; }
}
@@ -0,0 +1,990 @@
(() => {
'use strict';
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const ROUTE_ORDER = [
'hostname', 'ip', 'asn', 'email', 'url', 'person', 'person-link', 'takeover', 'shodan',
'scope-extension', 'external-relationship', 'other'
];
const ROUTE_LABELS = {
hostname: 'Hostnames', ip: 'IP addresses', asn: 'ASNs', email: 'Emails', url: 'URLs',
person: 'People', 'person-link': 'People links', takeover: 'Takeover evidence', shodan: 'Shodan evidence',
'scope-extension': 'Scope extensions', 'external-relationship': 'External relationships', other: 'Other'
};
const ACTION_FIELDS = {'dns-recursive': 'dns_recursive_depth'};
const SQLITE_SUFFIXES = ['.sqlite', '.sqlite3', '.db'];
const state = {
runs: [],
selectedId: null,
detail: null,
sources: [],
actions: [],
selectedSources: new Set(['crtsh']),
route: null,
theme: localStorage.getItem('runs-theme') || 'system',
pollTimer: null,
pollErrorShown: false,
resultTable: null,
screenshotUrls: new Map(),
};
const nodes = {
themeButton: $('#theme-button'), importButton: $('#import-button'), newRunButton: $('#new-run-button'),
loading: $('#loading-state'), empty: $('#empty-state'), detail: $('#run-detail'),
workspaceError: $('#workspace-error'), workspaceErrorMessage: $('#workspace-error-message'),
retryWorkspace: $('#retry-workspace-button'),
runCount: $('#run-count'), historySearch: $('#history-search'), runList: $('#run-list'), historyEmpty: $('#history-empty'),
detailTarget: $('#detail-target'), detailRunId: $('#detail-run-id'), statusChips: $('#status-chips'), cancel: $('#cancel-run-button'),
runFacts: $('#run-facts'), lifecycleTrack: $('#lifecycle-track'), lifecycleNote: $('#lifecycle-note'),
resultsSection: $('#results-section'),
activityBands: $('#activity-bands'), requestOptions: $('#request-options'), providerBody: $('#provider-body'),
providerSummary: $('#provider-summary'), providerOutcomeSummary: $('#provider-outcome-summary'), providerEmpty: $('#provider-empty'),
providerDetails: $('#provider-details'), resultsSummary: $('#results-summary'),
routeTabs: $('#route-tabs'), resultsEmpty: $('#results-empty'), resultsEmptyTitle: $('#results-empty-title'),
resultsEmptyCopy: $('#results-empty-copy'), resultWorkbench: $('#result-workbench'),
routeOverflowCue: $('#route-overflow-cue'),
resultSearch: $('#result-search'), routeCount: $('#route-count'), copySelected: $('#copy-route-button'),
exportJsonl: $('#export-jsonl-button'), screenshotSection: $('#screenshot-section'),
screenshotGallery: $('#screenshot-gallery'), logSection: $('#log-section'), logOutput: $('#run-log-output'),
newRunDialog: $('#new-run-dialog'), newRunForm: $('#new-run-form'), sourceSearch: $('#source-search'), sourceGroups: $('#source-groups'),
sourceCapability: $('#source-capability'), selectCapability: $('#select-capability-button'),
selectP0: $('#select-p0-button'), clearP0: $('#clear-p0-button'),
dnsResolvers: $('#dns-resolvers'), dnsResolverFile: $('#dns-resolver-file'),
activitySummary: $('#activity-summary'), newRunError: $('#new-run-error'), submitRun: $('#submit-run-button'),
importDialog: $('#import-dialog'), importForm: $('#import-form'), resultFile: $('#result-file'), fileLabel: $('#file-label'),
importError: $('#import-error'), submitImport: $('#submit-import-button'), screenshotDialog: $('#screenshot-dialog'),
screenshotDialogTitle: $('#screenshot-dialog-title'), screenshotDialogImage: $('#screenshot-dialog-image'),
toast: $('#toast'), announcer: $('#announcer'),
};
for (const tip of $$('.help-tip')) tip.setAttribute('aria-description', tip.dataset.tooltip);
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
})[character]);
}
function safeClass(value) {
return String(value ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '-');
}
function errorMessage(payload, fallback) {
const detail = payload?.detail;
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) return detail.map(item => item.msg || 'Invalid value').join('. ');
return fallback;
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
const response = await fetch(path, {...options, headers});
if (!response.ok) {
let payload = null;
try { payload = await response.json(); } catch { /* response has no JSON body */ }
const error = new Error(errorMessage(payload, `${response.status} ${response.statusText}`));
error.status = response.status;
throw error;
}
return response;
}
function announce(message) {
nodes.announcer.textContent = '';
requestAnimationFrame(() => { nodes.announcer.textContent = message; });
}
let toastTimer = null;
function toast(message, isError = false) {
clearTimeout(toastTimer);
nodes.toast.textContent = message;
nodes.toast.classList.toggle('error', isError);
nodes.toast.hidden = false;
toastTimer = setTimeout(() => { nodes.toast.hidden = true; }, 4200);
}
function dismissToast() {
clearTimeout(toastTimer);
nodes.toast.hidden = true;
}
function setBusy(button, busy, label) {
if (!button.dataset.idleLabel) button.dataset.idleLabel = button.textContent;
button.disabled = busy;
button.textContent = busy ? label : button.dataset.idleLabel;
}
function showFormError(node, message) {
node.textContent = message;
node.hidden = !message;
}
function formatDate(value) {
if (!value) return 'Not yet';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat(undefined, {
month: 'short', day: 'numeric', year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined,
hour: 'numeric', minute: '2-digit', second: '2-digit'
}).format(date);
}
function formatDuration(started, completed) {
if (!started) return 'Not started';
if (!completed) return 'In progress';
const seconds = Math.max(0, Math.round((new Date(completed) - new Date(started)) / 1000));
if (!Number.isFinite(seconds)) return 'Unknown';
if (seconds < 60) return `${seconds} sec`;
const minutes = Math.floor(seconds / 60);
return `${minutes} min ${seconds % 60} sec`;
}
function statusChip(status, prefix = '') {
if (!status) return '';
const label = prefix ? `${prefix}: ${status}` : status;
return `<span class="status-chip ${safeClass(status)}">${escapeHtml(label)}</span>`;
}
function applyTheme() {
if (!['system', 'light', 'dark'].includes(state.theme)) state.theme = 'system';
document.documentElement.dataset.theme = state.theme;
nodes.themeButton.textContent = `Theme: ${state.theme}`;
nodes.themeButton.setAttribute('aria-label', `Color theme is ${state.theme}. Change color theme`);
localStorage.setItem('runs-theme', state.theme);
}
function cycleTheme() {
const themes = ['system', 'light', 'dark'];
state.theme = themes[(themes.indexOf(state.theme) + 1) % themes.length];
applyTheme();
toast(`${state.theme[0].toUpperCase()}${state.theme.slice(1)} theme selected.`);
}
function openDialog(dialog, focusSelector) {
const formError = dialog.querySelector('.form-error');
if (formError) showFormError(formError, '');
if (!dialog.open) dialog.showModal();
const focusTarget = focusSelector ? dialog.querySelector(focusSelector) : dialog.querySelector('input, button');
requestAnimationFrame(() => focusTarget?.focus());
}
function closeDialog(dialog) {
if (dialog?.open) dialog.close();
}
async function loadWorkspace() {
nodes.newRunButton.disabled = true;
nodes.loading.hidden = false;
nodes.empty.hidden = true;
nodes.detail.hidden = true;
nodes.workspaceError.hidden = true;
const [catalogResponse, runsResponse] = await Promise.all([
api('/api/v1/sources'), api('/api/v1/runs')
]);
const catalog = await catalogResponse.json();
state.sources = catalog.sources;
state.actions = catalog.actions;
nodes.newRunButton.disabled = false;
const capabilities = [...new Set(state.sources.flatMap(source => source.capabilities || []))].sort();
nodes.sourceCapability.innerHTML = '<option value="">Choose result type</option>' + capabilities.map(capability => `<option value="${escapeHtml(capability)}">${escapeHtml(capability)}</option>`).join('');
state.runs = await runsResponse.json();
nodes.loading.hidden = true;
renderHistory();
if (!state.runs.length) {
state.selectedId = null;
nodes.empty.hidden = false;
return;
}
const preferred = state.runs.some(run => run.run_id === state.selectedId) ? state.selectedId : state.runs[0].run_id;
await selectRun(preferred);
}
function filteredRuns() {
const query = nodes.historySearch.value.trim().toLowerCase();
if (!query) return state.runs;
return state.runs.filter(run => [run.target, run.run_id, run.status, run.origin, ...(run.activities || [])].some(value => String(value).toLowerCase().includes(query)));
}
function renderHistory() {
const focusedRunId = document.activeElement?.dataset.runId;
nodes.runCount.textContent = state.runs.length;
const runs = filteredRuns();
nodes.historyEmpty.hidden = runs.length > 0;
nodes.historyEmpty.querySelector('p').textContent = state.runs.length ? 'No runs match this search.' : 'No saved runs yet.';
nodes.runList.innerHTML = runs.map(run => `
<button class="run-item ${run.run_id === state.selectedId ? 'selected' : ''}" type="button"
data-run-id="${escapeHtml(run.run_id)}" aria-pressed="${run.run_id === state.selectedId}">
<span class="run-target" title="${escapeHtml(run.target)}">${escapeHtml(run.target)}</span>
${statusChip(run.status)}
<span class="run-meta">${escapeHtml(formatDate(run.created_at))} · ${escapeHtml(run.origin)} · ${escapeHtml((run.activities || []).join('/'))}</span>
<span class="run-results">${formatCount(run.result_count, 'result')}</span>
</button>`).join('');
if (focusedRunId) [...nodes.runList.children].find(button => button.dataset.runId === focusedRunId)?.focus({preventScroll: true});
}
function formatCount(value, singular, plural = `${singular}s`) {
const count = Number(value || 0);
return `${count.toLocaleString()} ${count === 1 ? singular : plural}`;
}
function isTerminalStatus(status) {
return ['completed', 'failed', 'cancelled'].includes(status);
}
async function selectRun(runId) {
stopPolling();
state.selectedId = runId;
state.detail = null;
nodes.loading.hidden = false;
nodes.detail.hidden = true;
nodes.cancel.hidden = true;
renderHistory();
try {
const response = await api(`/api/v1/runs/${encodeURIComponent(runId)}`);
const detail = await response.json();
if (state.selectedId !== runId) return null;
state.detail = detail;
renderDetail();
if (!isTerminalStatus(state.detail.status)) startPolling();
return null;
} catch (error) {
if (state.selectedId !== runId) return null;
nodes.loading.hidden = true;
toast(`Could not load the run: ${error.message}. Select it again to retry.`, true);
return error;
}
}
function renderFacts(run) {
const facts = [
['Origin', run.origin], ['Submitted', formatDate(run.created_at)], ['Started', formatDate(run.started_at)],
['Duration', formatDuration(run.started_at, run.completed_at)], ['Results', Number(run.result_count || 0).toLocaleString()]
];
nodes.runFacts.innerHTML = facts.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join('');
}
function renderLifecycle(run) {
const terminalLabel = run.status === 'completed' ? 'Completed' : run.status === 'failed' ? 'Failed' : run.status === 'cancelled' ? 'Cancelled' : 'Terminal';
const steps = [['Submitted', run.created_at], ['Started', run.started_at]];
if (run.cancellation_requested_at) steps.push(['Cancellation requested', run.cancellation_requested_at]);
steps.push([terminalLabel, run.completed_at]);
nodes.lifecycleTrack.innerHTML = steps.map(([label, time]) => `
<li class="lifecycle-step ${time ? 'reached' : ''}"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(formatDate(time))}</span></li>`).join('');
const notes = {
queued: 'Waiting for the single local worker.', running: 'The isolated child process owns the finite execution.',
cancelling: 'Termination requested; forced termination follows after the grace period.', cancelled: 'The child can no longer produce work.',
completed: 'Lifecycle is terminal. Evidence completeness is reported separately.', failed: run.error || 'The run ended without a successful lifecycle completion.'
};
nodes.lifecycleNote.textContent = notes[run.status] || '';
}
function renderAuthorization(run) {
const active = new Set(run.activities || []);
nodes.activityBands.innerHTML = ['P0', 'P1', 'P2'].map(activity => `
<div class="activity-band ${active.has(activity) ? `active ${activity.toLowerCase()}` : ''}">
<strong>${activity}</strong><span>${active.has(activity) ? 'Selected' : 'Off'}</span>
</div>`).join('');
const request = run.request || {};
const sources = request.sources?.join(', ') || 'Not recorded';
const options = [
['Sources', sources], ['Result limit', request.limit ?? 'Imported evidence'],
['Result start offset', request.start ?? 'Not recorded'],
['Whole-run deadline', request.deadline_seconds ? `${request.deadline_seconds} seconds` : 'Not applicable'],
['Proxy transport', request.proxies ? 'Selected' : 'Off'],
['DNS lookup (/24 reverse expansion)', request.dns_lookup ? 'Selected' : 'Off'],
['DNS resolution', request.dns_resolve ? 'Selected' : 'Off'], ['DNS brute force', request.dns_brute ? 'Selected' : 'Off'],
['DNS resolver vantages', request.dns_resolvers?.join(', ') || 'Not recorded'],
['Recursive DNS depth', request.dns_recursive_depth ?? 'Not recorded'],
['Recursive DNS query budget', request.dns_recursive_query_limit ?? 'Not recorded'],
['Recursive DNS runtime', request.dns_recursive_runtime_seconds ? `${request.dns_recursive_runtime_seconds} seconds` : 'Not recorded'],
['Screenshots', request.screenshot ? 'Selected' : 'Off'],
['Takeover transport', request.takeover ? (request.proxies ? 'Configured proxy' : 'Direct') : 'Off'],
['API endpoint interaction', request.api_scan ? 'Selected' : 'Off']
];
if (request.filename) options.unshift(['Imported file', request.filename]);
nodes.requestOptions.innerHTML = options.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join('');
}
function sourceName(execution) { return execution.source || 'Unknown source'; }
function executionName(execution) { return execution.source || execution.action || 'Unknown producer'; }
function executionKind(execution) { return execution.source ? 'Source' : execution.action ? 'Action' : 'Unknown'; }
function credentialRequirement(source) {
const credentials = source?.credentials || [];
if (!credentials.length) return '';
const labels = credentials.map(value => value.replaceAll('-', ' ').replace(/^api /, 'API '));
return `Credentials required: ${labels.join(', ')}`;
}
function executionReason(execution) {
const errorType = execution.error_type;
if (execution.stop_reason === 'missing-credentials') {
return 'Required credentials were not configured; add them, then retry.';
}
if (execution.source && execution.status === 'skipped' && errorType === 'SourceDidNotStart') {
const requirement = credentialRequirement(state.sources.find(source => source.name === sourceName(execution)));
return requirement
? `${requirement}. Source did not start; verify configuration or inspect the child log, then retry.`
: 'Source did not start; inspect the child log, then retry.';
}
return errorType || execution.stop_reason?.replaceAll('-', ' ') || '-';
}
function renderExecutions(run) {
const executions = [...(run.source_executions || []), ...(run.action_executions || [])];
const counts = {completed: 0, partial: 0, skipped: 0, failed: 0, 'rate-limited': 0};
let zeroResultCount = 0;
for (const execution of executions) {
if (Object.hasOwn(counts, execution.status)) counts[execution.status] += 1;
if (execution.status === 'completed' && Number(execution.result_count || 0) === 0) zeroResultCount += 1;
}
nodes.providerSummary.textContent = executions.length;
nodes.providerOutcomeSummary.hidden = executions.length === 0;
nodes.providerOutcomeSummary.textContent = `${counts.completed} completed (${zeroResultCount} zero-result) / ${counts.partial} partial / ${counts.skipped} skipped / ${counts.failed} failed${counts['rate-limited'] ? ` / ${counts['rate-limited']} rate-limited` : ''}`;
nodes.providerEmpty.hidden = executions.length > 0;
nodes.providerBody.innerHTML = executions.map(execution => `
<tr><td>${escapeHtml(executionName(execution))}</td><td>${executionKind(execution)}</td><td>${statusChip(execution.status || 'unknown')}</td>
<td>${Number(execution.result_count || 0).toLocaleString()}</td><td>${execution.duration_ms == null ? '-' : `${Math.round(execution.duration_ms).toLocaleString()} ms`}</td>
<td>${escapeHtml(executionReason(execution))}</td></tr>`).join('');
}
function groupedResults() {
const groups = new Map();
for (const result of state.detail?.results || []) {
const type = result.type || 'other';
if (!groups.has(type)) groups.set(type, []);
groups.get(type).push(result);
}
return [...groups.entries()].sort(([left], [right]) => {
const leftIndex = ROUTE_ORDER.indexOf(left);
const rightIndex = ROUTE_ORDER.indexOf(right);
return (leftIndex < 0 ? 999 : leftIndex) - (rightIndex < 0 ? 999 : rightIndex) || left.localeCompare(right);
});
}
function dnsFormatter(cell) {
const value = cell.getValue() || 'not-captured';
return `<span class="dns-label ${safeClass(value)}">${escapeHtml(value.replaceAll('-', ' '))}</span>`;
}
function columnTextFilter(headerValue, rowValue) {
const query = String(headerValue || '').trim().toLowerCase().replaceAll('-', ' ');
const value = Array.isArray(rowValue) ? rowValue.join(' ') : rowValue || 'not-captured';
return String(value).toLowerCase().replaceAll('-', ' ').includes(query);
}
function resultActionFormatter(cell) {
const target = escapeHtml(cell.getRow().getData().value);
return `<div class="result-actions">
<button class="button small" type="button" data-run-action="screenshot" aria-label="Take screenshot of ${target} (P2)">Screenshot (P2)</button>
<button class="button small" type="button" data-run-action="dns_brute" aria-label="DNS brute force ${target} (P1)">DNS brute (P1)</button>
</div>`;
}
function mountResultTable(rows) {
state.resultTable?.destroy();
nodes.copySelected.disabled = true;
nodes.copySelected.textContent = 'Copy selected';
const columns = [
{title: 'Value', field: 'value', formatter: cell => `<span class="value-cell">${escapeHtml(cell.getValue())}</span>`, minWidth: 260, widthGrow: 2, headerFilter: 'input', headerFilterFunc: columnTextFilter, headerFilterPlaceholder: 'Filter values'},
{title: 'DNS', field: 'dns_status', formatter: dnsFormatter, width: 130, responsive: 1, headerFilter: 'input', headerFilterFunc: columnTextFilter, headerFilterPlaceholder: 'Filter DNS'},
];
if (state.route === 'hostname') {
columns.push({
title: 'Actions', field: 'value', formatter: resultActionFormatter, headerSort: false,
minWidth: 265, width: 265, responsive: 0, resizable: false,
cellClick: (event, cell) => {
const button = event.target.closest('[data-run-action]');
if (!button) return;
event.stopPropagation();
queueResultAction(button.dataset.runAction, cell.getRow().getData().value, button);
},
});
}
state.resultTable = new Tabulator(nodes.resultWorkbench.querySelector('#result-grid'), {
data: rows,
layout: 'fitColumns',
responsiveLayout: 'collapse',
resizableColumnGuide: true,
columnDefaults: {resizable: true},
selectableRows: true,
rowHeader: {
formatter: 'rowSelection', titleFormatter: 'rowSelection', headerSort: false,
width: 48, widthGrow: 0, resizable: false, frozen: true, headerHozAlign: 'center', hozAlign: 'center'
},
maxHeight: 590,
placeholder: 'No results match this filter.',
pagination: true,
paginationMode: 'local',
paginationSize: 15,
paginationSizeSelector: [15, 30, 60, 120],
paginationCounter: 'rows',
initialSort: [{column: 'value', dir: 'asc'}],
columns
});
state.resultTable.on('rowSelectionChanged', selected => {
nodes.copySelected.disabled = selected.length === 0;
nodes.copySelected.textContent = selected.length ? `Copy selected (${selected.length})` : 'Copy selected';
});
}
function renderResults(run) {
const focusedRoute = document.activeElement?.dataset.route;
const groups = groupedResults();
const total = (run.results || []).length;
if (total) {
nodes.resultsSummary.textContent = `${formatCount(total, 'normalized result')} across ${formatCount(groups.length, 'route')}.`;
} else if (run.status === 'completed') {
const emptySources = (run.source_executions || [])
.filter(execution => execution.status === 'completed' && Number(execution.result_count || 0) === 0)
.map(sourceName);
let sourceSummary = 'No normalized evidence was returned.';
if (emptySources.length === 1) sourceSummary = `${emptySources[0]} returned no normalized evidence.`;
if (emptySources.length > 1) sourceSummary = `${emptySources.length} selected sources returned no normalized evidence.`;
const outcomeSummary = nodes.providerOutcomeSummary.hidden ? '' : ` · ${nodes.providerOutcomeSummary.textContent}`;
nodes.resultsSummary.textContent = `0 normalized results${outcomeSummary}.`;
nodes.resultsEmptyTitle.textContent = 'Enumeration completed';
nodes.resultsEmptyCopy.textContent = `${sourceSummary} The retained evidence record is ${run.evidence_status || 'not recorded'}.`;
} else if (run.status === 'failed' || run.status === 'cancelled') {
const evidenceStatus = run.evidence_status || 'not recorded';
nodes.resultsSummary.textContent = '0 normalized results.';
nodes.resultsEmptyTitle.textContent = run.status === 'failed' ? 'Enumeration failed' : 'Enumeration cancelled';
nodes.resultsEmptyCopy.textContent = run.status === 'failed'
? `${run.error || 'The enumeration failed.'} The retained evidence record is ${evidenceStatus}.`
: `The enumeration was cancelled. The retained evidence record is ${evidenceStatus}.`;
} else {
nodes.resultsSummary.textContent = 'Queued and running records remain visible before terminal evidence exists.';
nodes.resultsEmptyTitle.textContent = 'No normalized evidence yet';
nodes.resultsEmptyCopy.textContent = nodes.resultsSummary.textContent;
}
nodes.resultsEmpty.hidden = total > 0;
nodes.resultWorkbench.hidden = total === 0;
nodes.routeTabs.hidden = total === 0;
nodes.routeOverflowCue.hidden = total === 0;
nodes.exportJsonl.disabled = !run.evidence_status;
nodes.copySelected.disabled = true;
if (!total) {
nodes.routeTabs.innerHTML = '';
state.resultTable?.destroy();
state.resultTable = null;
return;
}
if (!groups.some(([type]) => type === state.route)) state.route = groups[0][0];
nodes.routeTabs.innerHTML = groups.map(([type, results]) => `
<button class="route-tab ${type === state.route ? 'active' : ''}" type="button" data-route="${escapeHtml(type)}" aria-pressed="${type === state.route}">
${escapeHtml(ROUTE_LABELS[type] || type)} <span class="count-badge">${results.length}</span>
</button>`).join('');
const rows = groups.find(([type]) => type === state.route)?.[1] || [];
nodes.routeCount.textContent = rows.length;
nodes.resultSearch.value = '';
mountResultTable(rows);
if (focusedRoute) {
requestAnimationFrame(() => nodes.routeTabs.querySelector(`[data-route="${CSS.escape(focusedRoute)}"]`)?.focus({preventScroll: true}));
}
}
function revokeScreenshots() {
for (const url of state.screenshotUrls.values()) URL.revokeObjectURL(url);
state.screenshotUrls.clear();
}
async function loadScreenshot(screenshot, frame) {
try {
const response = await api(screenshot.url);
const objectUrl = URL.createObjectURL(await response.blob());
state.screenshotUrls.set(screenshot.url, objectUrl);
frame.innerHTML = `<img src="${escapeHtml(objectUrl)}" alt="Screenshot preview of ${escapeHtml(screenshot.target)}">`;
} catch {
frame.textContent = 'Preview unavailable. Reload the run to retry.';
}
}
function renderScreenshots(run) {
revokeScreenshots();
const screenshots = run.screenshots || [];
nodes.screenshotSection.hidden = screenshots.length === 0;
nodes.screenshotGallery.innerHTML = screenshots.map((screenshot, index) => `
<button class="screenshot-card" type="button" data-screenshot-index="${index}">
<span class="screenshot-frame">Loading managed artifact…</span>
<strong title="${escapeHtml(screenshot.target)}">${escapeHtml(screenshot.target)}</strong>
<small>${escapeHtml(screenshot.name)}</small>
</button>`).join('');
screenshots.forEach((screenshot, index) => loadScreenshot(screenshot, nodes.screenshotGallery.children[index].querySelector('.screenshot-frame')));
}
function renderDetail(previousRun = null) {
const run = state.detail;
nodes.loading.hidden = true;
nodes.empty.hidden = true;
nodes.detail.hidden = false;
nodes.detailTarget.textContent = run.target;
nodes.detailRunId.textContent = run.run_id;
nodes.statusChips.innerHTML = `${statusChip(run.status, 'Lifecycle')}${statusChip(run.evidence_status, 'Evidence')}`;
nodes.cancel.hidden = !['queued', 'running', 'cancelling'].includes(run.status);
nodes.cancel.disabled = run.status === 'cancelling';
nodes.cancel.textContent = run.status === 'cancelling' ? 'Cancellation in progress' : 'Request cancellation';
if (isTerminalStatus(run.status)) {
nodes.detail.insertBefore(nodes.resultsSection, nodes.runFacts);
} else {
nodes.detail.insertBefore(nodes.resultsSection, nodes.providerDetails);
}
renderFacts(run);
renderLifecycle(run);
renderAuthorization(run);
renderExecutions(run);
if (!previousRun || previousRun.status !== run.status || JSON.stringify(previousRun.results) !== JSON.stringify(run.results)) {
renderResults(run);
}
if (!previousRun || JSON.stringify(previousRun.screenshots) !== JSON.stringify(run.screenshots)) {
renderScreenshots(run);
}
nodes.logSection.hidden = !run.log;
nodes.logOutput.textContent = run.log || '';
}
async function refreshSelected() {
if (!state.selectedId) return;
const selectedId = state.selectedId;
try {
const [detailResponse, runsResponse] = await Promise.all([
api(`/api/v1/runs/${encodeURIComponent(selectedId)}`), api('/api/v1/runs')
]);
const previousDetail = state.detail;
const previousStatus = previousDetail?.status;
const [detail, runs] = await Promise.all([detailResponse.json(), runsResponse.json()]);
if (state.selectedId !== selectedId) return;
state.detail = detail;
state.runs = runs;
state.pollErrorShown = false;
renderHistory();
renderDetail(previousDetail);
if (state.detail.status !== previousStatus) {
if (previousStatus === 'queued') dismissToast();
announce(`Run lifecycle is now ${state.detail.status}.`);
}
if (isTerminalStatus(state.detail.status)) stopPolling();
} catch (error) {
if (state.selectedId !== selectedId) return;
if (!state.pollErrorShown) {
state.pollErrorShown = true;
toast(`Could not refresh the run: ${error.message}. Retrying automatically.`, true);
}
}
}
async function pollSelected() {
state.pollTimer = null;
await refreshSelected();
if (state.selectedId && state.detail && !isTerminalStatus(state.detail.status)) {
state.pollTimer = setTimeout(pollSelected, 1200);
}
}
function startPolling() {
stopPolling();
state.pollTimer = setTimeout(pollSelected, 1200);
}
function stopPolling() {
if (state.pollTimer) clearTimeout(state.pollTimer);
state.pollTimer = null;
state.pollErrorShown = false;
}
function renderSourceGroups(filter = '') {
const query = filter.trim().toLowerCase();
const groups = ['P0', 'P1', 'P2'].map(activity => [activity, state.sources.filter(source => {
if (source.activity !== activity) return false;
const haystack = [source.name, ...(source.capabilities || [])].join(' ').toLowerCase();
return !query || haystack.includes(query);
})]);
nodes.sourceGroups.innerHTML = groups.map(([activity, sources]) => `
<section class="source-group" data-activity="${activity}">
<h3>${activity} · ${activity === 'P0' ? 'Passive' : activity === 'P1' ? 'DNS interaction' : 'Direct interaction'}</h3>
${sources.length ? sources.map(source => `
<label class="source-choice" title="${escapeHtml((source.capabilities || []).join(', '))}">
<input type="checkbox" value="${escapeHtml(source.name)}" ${state.selectedSources.has(source.name) ? 'checked' : ''}>
<span>${escapeHtml(source.name)}<small>${escapeHtml((source.capabilities || []).join(', '))}</small>${credentialRequirement(source) ? `<small class="credential-note">${escapeHtml(credentialRequirement(source))}</small>` : ''}</span>
</label>`).join('') : '<p class="source-group-empty">No matching sources.</p>'}
</section>`).join('');
}
function setP0Selection(selected) {
for (const source of state.sources.filter(source => source.activity === 'P0')) {
if (selected) state.selectedSources.add(source.name);
else state.selectedSources.delete(source.name);
}
renderSourceGroups(nodes.sourceSearch.value);
updateActivitySummary();
announce(selected ? 'All passive P0 sources selected.' : 'Passive P0 sources cleared.');
}
function selectCapability() {
const capability = nodes.sourceCapability.value;
if (!capability) return;
for (const source of state.sources) {
if ((source.capabilities || []).includes(capability)) state.selectedSources.add(source.name);
}
renderSourceGroups(nodes.sourceSearch.value);
updateActivitySummary();
announce(`Sources providing ${capability} selected.`);
}
function selectedActivities() {
const activities = new Set();
for (const source of state.sources) if (state.selectedSources.has(source.name)) activities.add(source.activity);
for (const action of state.actions) {
const field = nodes.newRunForm.elements[ACTION_FIELDS[action.name] || action.name.replaceAll('-', '_')];
const selected = action.name === 'dns-recursive'
? Number(field?.value) > 0
: field?.checked;
if (selected) activities.add(action.activity);
}
return activities;
}
function updateActivitySummary() {
const activities = selectedActivities();
nodes.activitySummary.textContent = `P0 ${activities.has('P0') ? 'selected' : 'off'} · P1 ${activities.has('P1') ? 'selected' : 'off'} · P2 ${activities.has('P2') ? 'selected' : 'off'}`;
nodes.activitySummary.style.borderColor = activities.has('P2') ? 'var(--danger)' : activities.has('P1') ? 'var(--warning)' : 'var(--accent)';
}
function openNewRun() {
nodes.newRunForm.reset();
nodes.newRunForm.elements.limit.value = 500;
nodes.newRunForm.elements.deadline_seconds.value = 1800;
state.selectedSources = new Set(state.sources.some(source => source.name === 'crtsh') ? ['crtsh'] : [state.sources[0]?.name].filter(Boolean));
nodes.sourceSearch.value = '';
nodes.sourceCapability.value = '';
renderSourceGroups();
updateActivitySummary();
openDialog(nodes.newRunDialog, '#run-target');
}
function openImport() {
nodes.importForm.reset();
nodes.fileLabel.textContent = 'Choose a JSONL or SQLite file';
openDialog(nodes.importDialog, '#result-file');
}
async function focusCreatedRun(runId) {
state.selectedId = runId;
const runsResponse = await api('/api/v1/runs');
state.runs = await runsResponse.json();
renderHistory();
const loadError = await selectRun(runId);
if (loadError) throw loadError;
return state.selectedId === runId && state.detail?.run_id === runId;
}
async function focusAcceptedRun(runId, acceptedMessage) {
const previousSelectedId = state.selectedId;
const previousDetail = state.detail;
try {
if (!await focusCreatedRun(runId)) {
toast(`${acceptedMessage}.`);
return false;
}
return true;
} catch (error) {
if (state.selectedId === runId) {
state.selectedId = previousSelectedId;
state.detail = previousDetail;
renderHistory();
if (previousDetail) {
renderDetail();
if (!isTerminalStatus(previousDetail.status)) startPolling();
} else {
nodes.loading.hidden = true;
nodes.detail.hidden = true;
}
}
toast(`${acceptedMessage}, but the run view could not refresh: ${error.message}. Do not submit it again; reload the page to view it.`, true);
return false;
}
}
async function queueResultAction(action, target, button) {
const label = action === 'screenshot' ? 'Screenshot' : 'DNS brute force';
const payload = {target, sources: [], [action]: true};
const resolvers = state.detail?.request?.dns_resolvers;
if (action === 'dns_brute' && Array.isArray(resolvers) && resolvers.length) {
payload.dns_resolvers = resolvers;
}
setBusy(button, true, 'Starting…');
try {
const response = await api('/api/v1/runs', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
const run = await response.json();
if (await focusAcceptedRun(run.run_id, `${label} for ${target} was queued`)) {
toast(`${label} for ${target} is ${state.detail?.status || 'submitted'}.`);
}
} catch (error) {
toast(`Could not start ${label.toLowerCase()}: ${error.message}.`, true);
} finally {
setBusy(button, false, '');
}
}
async function submitRun(event) {
event.preventDefault();
showFormError(nodes.newRunError, '');
const form = new FormData(nodes.newRunForm);
const actionSelected = state.actions.some(action => {
const field = ACTION_FIELDS[action.name] || action.name.replaceAll('-', '_');
return action.name === 'dns-recursive' ? Number(form.get(field)) > 0 : form.has(field);
});
if (!state.selectedSources.size && !actionSelected) {
showFormError(nodes.newRunError, 'Select at least one discovery source or additional activity.');
return;
}
const payload = {
target: form.get('target'), sources: [...state.selectedSources], limit: Number(form.get('limit')),
start: Number(form.get('start')), deadline_seconds: Number(form.get('deadline_seconds')),
proxies: form.has('proxies'), dns_lookup: form.has('dns_lookup'), dns_resolve: form.has('dns_resolve'),
dns_resolvers: String(form.get('dns_resolvers')).split(',').map(value => value.trim()),
dns_recursive_depth: Number(form.get('dns_recursive_depth')),
dns_recursive_query_limit: Number(form.get('dns_recursive_query_limit')),
dns_recursive_runtime_seconds: Number(form.get('dns_recursive_runtime_seconds')),
dns_brute: form.has('dns_brute'), shodan: form.has('shodan'), screenshot: form.has('screenshot'),
takeover: form.has('takeover'), api_scan: form.has('api_scan'),
api_scan_paths: form.has('api_scan')
? String(form.get('api_scan_paths')).split(/\r?\n/).map(value => value.trim()).filter(Boolean)
: []
};
setBusy(nodes.submitRun, true, 'Submitting…');
try {
const response = await api('/api/v1/runs', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
const run = await response.json();
closeDialog(nodes.newRunDialog);
const focused = await focusAcceptedRun(run.run_id, `Enumeration for ${run.target} was created`);
if (focused && state.detail?.status === 'queued') {
toast(`Enumeration for ${run.target} is queued.`);
}
} catch (error) {
showFormError(nodes.newRunError, error.message);
} finally {
setBusy(nodes.submitRun, false, '');
}
}
async function submitImport(event) {
event.preventDefault();
showFormError(nodes.importError, '');
const file = nodes.resultFile.files[0];
if (!file) {
showFormError(nodes.importError, 'Choose a JSONL or SQLite result file.');
return;
}
const lowerName = file.name.toLowerCase();
const fileKind = lowerName.endsWith('.jsonl') ? 'jsonl' : SQLITE_SUFFIXES.some(suffix => lowerName.endsWith(suffix)) ? 'sqlite' : null;
if (!fileKind) {
showFormError(nodes.importError, 'Choose a .jsonl, .sqlite, .sqlite3, or .db file.');
return;
}
if (fileKind === 'jsonl' && file.size > 10 * 1024 * 1024) {
showFormError(nodes.importError, 'JSONL file exceeds the 10 MiB limit.');
return;
}
setBusy(nodes.submitImport, true, 'Importing…');
try {
const path = fileKind === 'jsonl' ? '/api/v1/runs/import' : '/api/v1/runs/import-database';
const contentType = fileKind === 'jsonl' ? 'application/x-ndjson' : 'application/vnd.sqlite3';
const response = await api(`${path}?filename=${encodeURIComponent(file.name)}`, {
method: 'POST', headers: {'Content-Type': contentType}, body: file
});
const imported = await response.json();
closeDialog(nodes.importDialog);
if (fileKind === 'jsonl') {
if (await focusAcceptedRun(imported.run_id, `${file.name} was imported`)) {
toast(`Imported ${file.name} without executing discovery.`);
}
} else {
const importedIds = imported.imported_run_ids || [];
const skippedIds = imported.skipped_run_ids || [];
const selectedId = importedIds[0] || skippedIds[0];
if (selectedId) {
if (!await focusAcceptedRun(selectedId, `${file.name} was imported`)) return;
} else {
try {
const runsResponse = await api('/api/v1/runs');
state.runs = await runsResponse.json();
renderHistory();
} catch (error) {
toast(`${file.name} was imported, but run history could not refresh: ${error.message}. Do not import it again; reload the page to view it.`, true);
return;
}
}
toast(`Imported ${formatCount(importedIds.length, 'run')} from ${file.name}; ${skippedIds.length} already present.`);
}
} catch (error) {
showFormError(nodes.importError, error.message);
} finally {
setBusy(nodes.submitImport, false, '');
}
}
async function requestCancellation() {
if (!state.selectedId) return;
const selectedId = state.selectedId;
nodes.cancel.disabled = true;
let detail;
try {
const response = await api(`/api/v1/runs/${encodeURIComponent(selectedId)}/cancel`, {method: 'POST'});
detail = await response.json();
} catch (error) {
if (state.selectedId !== selectedId) return;
nodes.cancel.disabled = false;
toast(`Could not request cancellation: ${error.message}. Refresh the run state and try again.`, true);
return;
}
if (state.selectedId !== selectedId) return;
state.detail = detail;
renderDetail();
if (state.detail.status === 'cancelling') startPolling();
else stopPolling();
try {
const runsResponse = await api('/api/v1/runs');
const runs = await runsResponse.json();
if (state.selectedId !== selectedId) return;
state.runs = runs;
renderHistory();
} catch (error) {
if (state.selectedId !== selectedId) return;
toast(`Cancellation was accepted, but run history could not refresh: ${error.message}. Do not request it again; reload the page to confirm it.`, true);
return;
}
toast(state.detail.status === 'cancelled' ? 'Queued enumeration cancelled.' : 'Cancellation requested.');
}
async function downloadServerExport() {
try {
const response = await api(`/api/v1/runs/${encodeURIComponent(state.selectedId)}/export`);
const disposition = response.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="([^"]+)"/);
downloadBlob(await response.blob(), match?.[1] || 'harvestview-results.jsonl');
} catch (error) {
toast(`Could not export results: ${error.message}. Keep the run open and try again.`, true);
}
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = Object.assign(document.createElement('a'), {href: url, download: filename});
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
toast(`Downloaded ${filename}.`);
}
async function copySelected() {
const selected = state.resultTable?.getSelectedRows().map(row => row.getData()) || [];
if (!selected.length) return;
const text = selected.map(result => result.value).join('\n');
try {
await navigator.clipboard.writeText(text);
toast(`Copied ${selected.length} selected ${ROUTE_LABELS[state.route] || state.route}.`);
} catch {
toast('Clipboard access was unavailable. Use the JSONL export instead.', true);
}
}
function openScreenshot(index) {
const screenshot = state.detail?.screenshots?.[index];
const objectUrl = state.screenshotUrls.get(screenshot?.url);
if (!screenshot || !objectUrl) {
toast('The screenshot preview is not available. Reload the run to retry.', true);
return;
}
nodes.screenshotDialogTitle.textContent = screenshot.target;
nodes.screenshotDialogImage.src = objectUrl;
nodes.screenshotDialogImage.alt = `Screenshot of ${screenshot.target}`;
openDialog(nodes.screenshotDialog, '[data-close-dialog]');
}
nodes.themeButton.addEventListener('click', cycleTheme);
nodes.retryWorkspace.addEventListener('click', start);
nodes.newRunButton.addEventListener('click', openNewRun);
nodes.importButton.addEventListener('click', openImport);
nodes.historySearch.addEventListener('input', renderHistory);
nodes.newRunForm.addEventListener('submit', submitRun);
nodes.importForm.addEventListener('submit', submitImport);
nodes.cancel.addEventListener('click', requestCancellation);
nodes.exportJsonl.addEventListener('click', downloadServerExport);
nodes.copySelected.addEventListener('click', copySelected);
nodes.resultSearch.addEventListener('input', event => {
const query = event.target.value.trim().toLowerCase();
state.resultTable?.setFilter(row => !query || row.value.toLowerCase().includes(query));
});
nodes.sourceSearch.addEventListener('input', event => renderSourceGroups(event.target.value));
nodes.selectCapability.addEventListener('click', selectCapability);
nodes.selectP0.addEventListener('click', () => setP0Selection(true));
nodes.clearP0.addEventListener('click', () => setP0Selection(false));
nodes.sourceGroups.addEventListener('change', event => {
if (!event.target.matches('input[type="checkbox"]')) return;
if (event.target.checked) state.selectedSources.add(event.target.value);
else state.selectedSources.delete(event.target.value);
updateActivitySummary();
});
nodes.newRunForm.addEventListener('change', updateActivitySummary);
nodes.dnsResolverFile.addEventListener('change', async () => {
const file = nodes.dnsResolverFile.files[0];
if (!file) return;
try {
const resolvers = (await file.text()).split(/\r?\n/).map(value => value.trim()).filter(Boolean);
nodes.dnsResolvers.value = resolvers.join(',');
announce(`${formatCount(resolvers.length, 'resolver address')} loaded from ${file.name}.`);
} catch {
toast(`Could not read ${file.name}. Choose a plain text resolver file and try again.`, true);
}
});
nodes.resultFile.addEventListener('change', () => {
const file = nodes.resultFile.files[0];
nodes.fileLabel.textContent = file ? `${file.name} · ${(file.size / 1024).toLocaleString(undefined, {maximumFractionDigits: 1})} KiB` : 'Choose a JSONL or SQLite file';
});
document.addEventListener('click', event => {
const runButton = event.target.closest('[data-run-id]');
if (runButton) selectRun(runButton.dataset.runId);
const routeButton = event.target.closest('[data-route]');
if (routeButton) {
state.route = routeButton.dataset.route;
renderResults(state.detail);
announce(`${ROUTE_LABELS[state.route] || state.route} route selected.`);
}
const screenshotButton = event.target.closest('[data-screenshot-index]');
if (screenshotButton) openScreenshot(Number(screenshotButton.dataset.screenshotIndex));
if (event.target.closest('[data-action="new-run"]')) openNewRun();
if (event.target.closest('[data-action="import"]')) openImport();
const closeButton = event.target.closest('[data-close-dialog]');
if (closeButton) closeDialog(closeButton.closest('dialog'));
});
for (const dialog of [nodes.newRunDialog, nodes.importDialog, nodes.screenshotDialog]) {
dialog.addEventListener('click', event => {
if (event.target === dialog) closeDialog(dialog);
});
}
window.addEventListener('beforeunload', () => {
stopPolling();
revokeScreenshots();
});
async function start() {
applyTheme();
try {
await loadWorkspace();
} catch (error) {
nodes.loading.hidden = true;
nodes.workspaceErrorMessage.textContent = error.message;
nodes.workspaceError.hidden = false;
}
}
start();
})();
@@ -0,0 +1,315 @@
<!doctype html>
<html lang="en" data-theme="system">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>HarvestView</title>
<link rel="icon" href="/static/harvestview/logo.webp">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/css/tabulator.min.css" integrity="sha512-t8I/asqzdu/MRgVLxVanQ/c5bhUA1qZ/zA432a/3nUh0kkd7P8Qch35wQvTODivf9D6Xv3h7F8p7ezcUyBOQrQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/static/harvestview/app.css?v={{VERSION}}-{{ASSET_VERSION}}">
</head>
<body>
<a class="skip-link" href="#workbench">Skip to run evidence</a>
<header class="app-header">
<a class="brand" href="/" aria-label="HarvestView home">
<img src="/static/harvestview/logo.webp" alt="">
<span class="brand-copy"><strong>HarvestView</strong><small>Analysis workspace for theHarvester</small></span>
<span class="version-badge" aria-label="theHarvester version {{VERSION}}">v{{VERSION}}</span>
</a>
<div class="header-context" aria-hidden="true">
<span class="signal-dot"></span>
<span>Local operator workspace</span>
</div>
<nav class="header-actions" aria-label="Run desk actions">
<button id="theme-button" class="header-button" type="button" aria-label="Change color theme" title="Cycle between system, light, and dark themes.">Theme: system</button>
<button id="import-button" class="header-button" type="button" title="Store existing JSONL evidence or completed runs from a theHarvester SQLite database without contacting the target.">Import result file</button>
<button id="new-run-button" class="header-button primary" type="button" title="Review the target, sources, and authorized activity before anything is queued." disabled>Start enumeration</button>
</nav>
</header>
<main class="app-shell">
<aside class="history-panel" aria-labelledby="history-title">
<div class="history-heading">
<div>
<p class="eyebrow">Durable history</p>
<h1 id="history-title">Enumeration runs</h1>
</div>
<span id="run-count" class="count-badge">0</span>
</div>
<label class="search-field" for="history-search">
<span class="sr-only">Search enumeration runs</span>
<span aria-hidden="true"></span>
<input id="history-search" type="search" autocomplete="off" placeholder="Find target or run ID">
</label>
<div id="run-list" class="run-list"></div>
<div id="history-empty" class="history-empty" hidden>
<span aria-hidden="true"></span>
<p>No runs match this search.</p>
</div>
</aside>
<section id="workbench" class="workbench" tabindex="-1">
<div id="loading-state" class="center-state">
<span class="loader" aria-hidden="true"></span>
<h2>Opening the run desk</h2>
<p>Loading local lifecycle and evidence records.</p>
</div>
<div id="workspace-error" class="center-state" hidden>
<div class="empty-mark" aria-hidden="true">!</div>
<h2>Could not open the run desk</h2>
<p id="workspace-error-message"></p>
<button id="retry-workspace-button" type="button" class="button primary">Retry</button>
</div>
<div id="empty-state" class="center-state empty-state" hidden>
<div class="empty-mark" aria-hidden="true">W</div>
<h2>No enumeration runs yet</h2>
<p>Start an explicitly authorized finite run, or import theHarvester JSONL or SQLite results.</p>
<div class="empty-actions">
<button type="button" class="button primary" data-action="new-run">Start enumeration</button>
<button type="button" class="button" data-action="import">Import result file</button>
</div>
<dl class="empty-guardrails">
<div><dt>P0</dt><dd>Passive collection is the default.</dd></div>
<div><dt>P1 / P2</dt><dd>DNS and direct interaction stay off until selected.</dd></div>
<div><dt>Local</dt><dd>One isolated worker owns one run at a time.</dd></div>
</dl>
</div>
<article id="run-detail" class="run-detail" hidden>
<header class="detail-header">
<div class="detail-identity">
<h2 id="detail-target">-</h2>
<p id="detail-run-id" class="mono detail-run-id">-</p>
</div>
<div class="detail-status">
<div id="status-chips" class="status-chips"></div>
<p id="provider-outcome-summary" class="provider-outcome-summary" hidden></p>
<button id="cancel-run-button" class="button danger" type="button" title="Prevent queued work from starting or ask the running child process to stop. Cancellation may not be immediate." hidden>Request cancellation</button>
</div>
</header>
<dl id="run-facts" class="run-facts"></dl>
<section class="lifecycle-section" aria-labelledby="lifecycle-title">
<div class="section-heading compact">
<div class="heading-with-help">
<h3 id="lifecycle-title">Lifecycle</h3>
<button class="help-tip" type="button" aria-label="Explain lifecycle" data-tooltip="Tracks control flow from submission through completion or cancellation. It does not describe evidence completeness.">?</button>
</div>
<p id="lifecycle-note" class="section-note"></p>
</div>
<ol id="lifecycle-track" class="lifecycle-track"></ol>
</section>
<div class="overview-grid">
<section class="overview-panel authorization-panel" aria-labelledby="authorization-title">
<div class="section-heading compact">
<div class="heading-with-help">
<h3 id="authorization-title">Authorized activity</h3>
<button class="help-tip" type="button" aria-label="Explain authorized activity" data-tooltip="P0 queries existing providers, P1 performs DNS interaction, and P2 contacts the target or causes direct interaction.">?</button>
</div>
</div>
<div id="activity-bands" class="activity-bands"></div>
<dl id="request-options" class="request-options"></dl>
</section>
</div>
<section id="results-section" class="results-section" aria-labelledby="results-title">
<div class="section-heading results-heading">
<div>
<div class="heading-with-help">
<h3 id="results-title">Result routes</h3>
<button class="help-tip" type="button" aria-label="Explain result routes" data-tooltip="Routes group normalized evidence by type. Search the route or filter individual columns; drag header dividers to resize. Copy uses selected rows; JSONL export includes the full run.">?</button>
</div>
<p id="results-summary" class="section-note">Choose a route to inspect its evidence.</p>
</div>
<div class="section-actions">
<button id="copy-route-button" class="button small" type="button" title="Copy only the selected rows from the open result route." disabled>Copy selected</button>
<button id="export-jsonl-button" class="button small" type="button" title="Download the complete normalized result stream as JSONL.">All JSONL</button>
</div>
</div>
<nav id="route-tabs" class="route-tabs" aria-label="Result routes"></nav>
<p id="route-overflow-cue" class="route-overflow-cue" hidden>Scroll sideways for more result routes →</p>
<div id="results-empty" class="results-empty" hidden>
<span aria-hidden="true"></span>
<h4 id="results-empty-title">No normalized evidence yet</h4>
<p id="results-empty-copy">Queued and running records remain visible before terminal evidence exists.</p>
</div>
<div id="result-workbench" class="result-workbench" hidden>
<div class="table-toolbar">
<label for="result-search">Filter this route
<input id="result-search" type="search" autocomplete="off" placeholder="Search values">
</label>
<p class="table-guidance">Filter each column below · Drag header dividers to resize</p>
<span id="route-count" class="count-badge quiet">0</span>
</div>
<div id="result-grid" aria-label="Selected result route"></div>
</div>
</section>
<details id="provider-details" class="provider-details">
<summary title="Show what each selected source and action returned, skipped, or failed, including timing and recovery details."><span id="provider-title">Execution outcomes</span><span id="provider-summary" class="count-badge quiet">0</span></summary>
<div class="provider-table-wrap">
<table class="provider-table">
<thead><tr><th>Producer</th><th>Kind</th><th>Outcome</th><th>Results</th><th>Duration</th><th>Reason</th></tr></thead>
<tbody id="provider-body"></tbody>
</table>
</div>
<p id="provider-empty" class="panel-empty" hidden>Execution outcomes appear after a source or action begins.</p>
</details>
<section id="screenshot-section" class="screenshot-section" aria-labelledby="screenshots-title" hidden>
<div class="section-heading">
<div><h3 id="screenshots-title">Screenshots</h3></div>
<p class="section-note">Only files captured inside this enumeration run are served.</p>
</div>
<div id="screenshot-gallery" class="screenshot-gallery"></div>
</section>
<details id="log-section" class="run-log" hidden>
<summary>Child process log</summary>
<pre id="run-log-output"></pre>
</details>
</article>
</section>
</main>
<dialog id="new-run-dialog" class="form-dialog wide-dialog" aria-labelledby="new-run-title">
<form id="new-run-form" class="dialog-content">
<header class="dialog-header">
<div><h2 id="new-run-title">Start an enumeration</h2></div>
<button class="icon-button dialog-close" type="button" data-close-dialog aria-label="Close new enumeration dialog">×</button>
</header>
<p>Choose only work you are authorized to perform. Passive sources are P0; DNS and direct interaction remain off until explicitly selected.</p>
<div class="form-grid three">
<label>Target hostname or IP
<input id="run-target" name="target" placeholder="example.com" autocomplete="off" required>
<small>Use an explicitly authorized target.</small>
</label>
<label>Result limit
<input id="run-limit" name="limit" type="number" min="1" max="10000" value="500" required>
<small>Applied per selected source.</small>
</label>
<label>Whole-run deadline
<input id="run-deadline" name="deadline_seconds" type="number" min="30" max="86400" value="1800" required>
<small>Termination grace is handled automatically.</small>
</label>
</div>
<details class="advanced-execution">
<summary>Advanced execution controls</summary>
<div class="form-grid three">
<label>Result start offset
<input id="run-start" name="start" type="number" min="0" value="0" required>
<small>Skip this many provider results before collection.</small>
</label>
<label>Recursive DNS depth
<input id="dns-recursive-depth" name="dns_recursive_depth" type="number" min="0" value="0" required>
<small>Zero keeps recursive discovery off.</small>
</label>
<label>Recursive query budget
<input id="dns-recursive-query-limit" name="dns_recursive_query_limit" type="number" min="1" value="3000" required>
<small>Hard cap across all resolver vantages.</small>
</label>
<label>Recursive runtime seconds
<input id="dns-recursive-runtime-seconds" name="dns_recursive_runtime_seconds" type="number" min="0.1" step="0.1" value="60" required>
<small>Finite wall-clock cap for recursive discovery.</small>
</label>
<label>DNS resolver addresses
<input id="dns-resolvers" name="dns_resolvers" value="{{DNS_RESOLVERS}}" required>
<small>One or more IP addresses. Recursive DNS requires exactly three.</small>
</label>
<label>Resolver text file
<input id="dns-resolver-file" type="file" accept=".txt,text/plain">
<small>Optional file with one IPv4 or IPv6 address per line.</small>
</label>
<label class="api-scan-paths">API endpoint paths
<textarea id="api-scan-paths" name="api_scan_paths" rows="3" placeholder="/api/v2&#10;/health"></textarea>
<small>Optional list for API endpoint interaction, one URL path per line. Leave empty to use the bundled list.</small>
</label>
<label class="inline-choice"><input type="checkbox" name="proxies"><span>Use configured proxies for discovery and takeover checks</span></label>
</div>
</details>
<fieldset class="source-fieldset">
<legend><span class="heading-with-help">Discovery sources <button class="help-tip" type="button" aria-label="Explain discovery sources" data-tooltip="Each selected source is queried during this finite run. Credential warnings identify sources that cannot start until configured.">?</button></span></legend>
<div class="source-tools">
<label class="source-search" for="source-search">Filter sources
<input id="source-search" type="search" autocomplete="off" placeholder="Name or capability">
</label>
<div class="source-actions" role="group" aria-label="Source selection">
<label for="source-capability">Add capability
<select id="source-capability"><option value="">Choose result type</option></select>
</label>
<button id="select-capability-button" class="button small" type="button">Add sources</button>
<button id="select-p0-button" class="button small" type="button" title="Select every passive P0 source without enabling P1 or P2 activity.">Select all P0</button>
<button id="clear-p0-button" class="button small" type="button" title="Clear all passive P0 source selections.">Clear</button>
</div>
</div>
<div id="source-groups" class="source-groups"></div>
</fieldset>
<fieldset class="action-fieldset">
<legend><span class="heading-with-help">Additional activity <button class="help-tip" type="button" aria-label="Explain additional activity" data-tooltip="These options expand authorization beyond selected sources. P1 performs DNS interaction; P2 can contact the target directly.">?</button></span></legend>
<div class="action-grid">
<label class="action-choice p0"><input type="checkbox" name="shodan"><span><strong>P0 · Shodan enrichment</strong><small>Query Shodan for discovered IP evidence.</small></span></label>
<label class="action-choice p1"><input type="checkbox" name="dns_lookup"><span><strong>P1 · DNS lookup</strong><small>Reverse-query every discovered IPv4 address and each address in its /24.</small></span></label>
<label class="action-choice p1"><input type="checkbox" name="dns_resolve"><span><strong>P1 · DNS resolution</strong><small>Resolve with the configured resolver addresses.</small></span></label>
<label class="action-choice p1"><input type="checkbox" name="dns_brute"><span><strong>P1 · DNS brute force</strong><small>Query candidate labels against DNS.</small></span></label>
<label class="action-choice p2"><input type="checkbox" name="screenshot"><span><strong>P2 · Screenshots</strong><small>Contact resolved hosts and capture pages.</small></span></label>
<label class="action-choice p2"><input type="checkbox" name="takeover"><span><strong>P2 · Takeover checks</strong><small>Contact discovered hosts, through configured proxies when enabled, for takeover evidence.</small></span></label>
<label class="action-choice p2"><input type="checkbox" name="api_scan"><span><strong>P2 · API endpoint interaction</strong><small>Contact the target for endpoint evidence.</small></span></label>
</div>
</fieldset>
<p id="activity-summary" class="activity-summary">P0 selected · P1 off · P2 off</p>
<p id="new-run-error" class="form-error" role="alert" hidden></p>
<footer class="dialog-actions">
<button class="button" type="button" data-close-dialog>Keep reviewing</button>
<button id="submit-run-button" class="button primary" type="submit">Start enumeration</button>
</footer>
</form>
</dialog>
<dialog id="import-dialog" class="form-dialog" aria-labelledby="import-title">
<form id="import-form" class="dialog-content">
<header class="dialog-header">
<div><h2 id="import-title">Import a result file</h2></div>
<button class="icon-button dialog-close" type="button" data-close-dialog aria-label="Close import dialog">×</button>
</header>
<p>Choose theHarvester JSONL evidence or a closed, checkpointed SQLite database. Import never contacts the target or replays a run. SQLite stores run evidence but does not embed screenshot files.</p>
<label class="file-drop" for="result-file">
<span class="file-mark" aria-hidden="true"></span>
<strong id="file-label">Choose a JSONL or SQLite file</strong>
<small>JSONL maximum: 10 MiB. SQLite uses the server import limit.</small>
<input id="result-file" name="result-file" type="file" accept=".jsonl,.sqlite,.sqlite3,.db,application/x-ndjson,application/vnd.sqlite3" required>
</label>
<p id="import-error" class="form-error" role="alert" hidden></p>
<footer class="dialog-actions">
<button class="button" type="button" data-close-dialog>Keep current view</button>
<button id="submit-import-button" class="button primary" type="submit">Import result file</button>
</footer>
</form>
</dialog>
<dialog id="screenshot-dialog" class="screenshot-dialog" aria-labelledby="screenshot-dialog-title">
<header class="screenshot-dialog-header">
<div><h2 id="screenshot-dialog-title">Screenshot</h2></div>
<button class="icon-button dialog-close" type="button" data-close-dialog aria-label="Close screenshot">×</button>
</header>
<img id="screenshot-dialog-image" src="/static/harvestview/logo.webp" alt="">
</dialog>
<div id="toast" class="toast-message" role="status" hidden></div>
<div id="announcer" class="sr-only" aria-live="polite"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tabulator-tables/6.5.2/js/tabulator.min.js" integrity="sha512-AF0YMSgc0Ui4IJPb4hJNSi16wFidZEQa6ZTCAeguF3h5glVnAPuz/JT2ai9ypKhsc9n6CEXBB+tMdxsv1q+rxg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="/static/harvestview/app.js?v={{VERSION}}-{{ASSET_VERSION}}"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Generated
+51
View File
@@ -1399,6 +1399,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "pytest-base-url"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" },
]
[[package]]
name = "pytest-playwright"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-base-url" },
{ name = "python-slugify" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/ef/172eb8e23c80491fc72f1401c72f9305663873649351306a38b18406b0c9/pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981", size = 17132, upload-time = "2026-05-18T10:16:15.919Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/71/1c545fac6a9054b52b3771238fb2dc6e8f1d0ccec116e1c7786ec191887c/pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933", size = 17143, upload-time = "2026-05-18T10:16:18.226Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -1411,6 +1439,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-slugify"
version = "8.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "text-unidecode" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
]
[[package]]
name = "python-socks"
version = "2.8.1"
@@ -1626,6 +1666,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "text-unidecode"
version = "1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
]
[[package]]
name = "theharvester"
source = { editable = "." }
@@ -1662,6 +1711,7 @@ dev = [
{ name = "mypy-extensions" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-playwright" },
{ name = "ruff" },
{ name = "ty" },
{ name = "types-certifi" },
@@ -1706,6 +1756,7 @@ dev = [
{ name = "mypy-extensions", specifier = "==1.1.0" },
{ name = "pytest", specifier = "==9.1.1" },
{ name = "pytest-asyncio", specifier = "==1.4.0" },
{ name = "pytest-playwright", specifier = "==0.8.0" },
{ name = "ruff", specifier = "==0.15.20" },
{ name = "ty", specifier = "==0.0.54" },
{ name = "types-certifi", specifier = "==2021.10.8.3" },