The dashboard is a FastAPI application that serves a browser UI directly
over the live SQLite database. It uses the same SQLAlchemy models the ingest
pipeline writes to, so what it shows and edits is the live data, not a copy.
It is where a reader reviews indicators, reports and quality flags, and
also where reports are uploaded and ingested and where the per-machine
settings in threatlens.conf are edited.
# with the virtual environment active; needs the dashboard extra:
# pip install "threatlens-cti[dashboard]" (or pip install -e ".[dashboard]" in a checkout)
threatlens-dashboard # threatlens.conf [dashboard], default http://127.0.0.1:8000
threatlens-dashboard --host 0.0.0.0 --port 8000 --reload # reachable from the local network
# or directly
uvicorn threatlens.dashboard.server:app --reload
# or in the background, from src/threatlens/dashboard/ (repo checkout only)
./start.sh [PORT] # also start.bat / start.ps1; stop with stop.*
The address comes from threatlens.conf [dashboard] host /
port (set by threatlens init),
default 127.0.0.1:8000; --host / --port
override it. With no threatlens.conf and a terminal, the
dashboard first offers to run the setup wizard.
The start scripts run the server in the background, write its PID to
.server.pid and its output to server.log, refuse to
start a second copy, and use the repo's .venv when there is one.
THREATLENS_DASHBOARD_HOST overrides the host. They are not part
of the pip package; threatlens-dashboard is.
The pages, their JS/CSS and these documentation pages are package data,
so a pip-installed copy serves the full UI and /docs/ with no
repo checkout.
At startup the dashboard checks threatlens.conf (see
setup check) and prints a
SETUP: line for each step not yet done: the file is missing, is
still the unedited template, or its [llm] settings have not been
calibrated. These are warnings only; the dashboard starts on built-in
defaults regardless.
127.0.0.1, keeps the dashboard to this machine. With
0.0.0.0 ([dashboard] host or --host) it is
reachable from the local network, and anyone who can reach it can edit and
delete database rows, upload files and change the configuration; the
dashboard prints a warning at startup when it listens on all interfaces.
The checkout's start.* scripts still default to
0.0.0.0 (THREATLENS_DASHBOARD_HOST changes it).At startup the dashboard opens the database folder
(db/ in a checkout, <data folder>/db/ when
pip-installed; see where files live) +
threatlens.conf [database] default (else
threatlens.db). THREATLENS_DB_URL or
THREATLENS_DB_FILE override that. The header's
Database dropdown switches to any other .db file
in that folder at runtime, without a restart. Every page, the API, the
database browser and newly queued ingest jobs follow the switch.
/)A header with links to the other pages, the database dropdown and four stat cards: indicator count, report count, open quality flags and mean quality score. Below it, three tabs:
| Tab | Shows | Actions |
|---|---|---|
| Indicators | Every stored indicator: type, value, confidence, quality score with its grade badge, sighting count, extraction engine, last seen. Paged (100 per page), sorted on the server so a sort spans every page. | Search by value; filter by type, quality-score range and engine (llm, python, unknown); sort by any column. Edit any field in a form; Delete (with its sightings and flags). Click a row for the indicator page. |
| Reports | Every ingested report: title (from its metadata, not the filename), mean quality score, filename, type, reliability, engine, indicator count, ingest time | Open the report page; Delete the report |
| Quality Flags | Flags raised by the quality checker: indicator, flag type, dimension, severity, detail, status. Newest first; defaults to open flags. | Filter by status (open, resolved, dismissed); Resolve or Dismiss a flag |
An indicator's engine is not stored on the indicator: it is the
combination of the engines of the reports it was seen in, so a value seen by
both reads llm+python.
/reports/{id})The report's metadata and quality summary, then a table of its indicators with the four dimension scores, quality score, confidence and flags. Under it is the original PDF, rendered page by page on the server with PyMuPDF, with every place an indicator appears highlighted. Rows and highlights link both ways: click a row to jump to the value in the PDF. The In PDF column counts how often each value was found; buttons step between pages with hits, and the view fits to page or width.
The matching is deliberately looser than an exact search, because the
stored value often differs from the printed one (wrapped URLs and hashes,
defanged forms). The PDF itself is not in the database:
raw_documents.file_path points at it in
docs/database/CTI/, so if the file is moved or deleted the view
is unavailable but the data is not affected.
/indicators/{id})Everything known about one indicator, with each number explained:
score_components;/uploads)Lists the PDFs in the report folder, the folder ingest reads from
(docs/database/CTI/ in a checkout, gitignored;
<data folder>/reports/ when pip-installed; created on the
first upload or download), with size, date and whether each is already in the active database
(matched by path, or by content hash for a renamed copy). Files can be
uploaded (several at once, optionally overwriting), opened in the browser,
or removed.
This page only changes files on disk. Uploading does not ingest, and
removing a file does not touch the database; that is the Reports tab's
delete. Uploads are accepted only if the content starts with
%PDF-, are written under a temporary name and renamed when
complete, and must be plain filenames (no paths).
/ingest)Runs the ingest pipeline (extract, quality checks, persist, rescore) on chosen reports:
llm or python, with a status line showing which model the llm engine would use and whether the Ollama server has it. The status chip reads ready, unavailable, or ready, setup incomplete when the server is fine but threatlens.conf is missing, unedited or uncalibrated; the missing steps are listed beside it and in the chip's tooltip. The same warnings appear as [WARN] lines at the top of each job's log.-v level.How the queue works:
ingest_worker.py process. Every job writes the same SQLite file, and one LLM job already keeps Ollama busy. A separate process means a slow job can be cancelled by killing it, and an extractor crash cannot take the dashboard down.@@progress lines and, during LLM verification, its batch counter, which is where nearly all the time goes. The log is polled from the page while the job runs.URL downloads are locked down because the dashboard may be reachable from
the network: http(s) on ports 80/443 only, no credentials in the URL, no
proxies, and every connection, including each redirect hop, must go to an
address the server has itself resolved and checked to be publicly
routable. A response is accepted only if it starts with
%PDF-.
/config)Views and edits threatlens.conf, the same file the CLI and the
ingest worker read:
| Section | Settings |
|---|---|
| Ollama | Server URL. The model pickers query the server for its installed models. |
| Models | Verifier model; profiler model (blank = same as verifier) |
Dashboard address ([dashboard]) | Not edited here; set by threatlens init or in the file, used at the next start. A save keeps it. |
Data folder ([paths]) | Not edited here; set data_dir in the file or THREATLENS_HOME, then restart. The page shows the database and report folders in use, and a save keeps the setting. |
LLM tuning ([llm]) | Not edited here. Batch size, context window, timeouts and retries are written by threatlens-calibrate (or by hand); a save from this page keeps them, and keeps the [calibration] record. |
| Database | Default database, chosen from the files in the database folder. Applies at the next dashboard start; the header dropdown switches the live one. |
| Quality weights | The four dimension weights. Each 0–1, and they must sum to 1.00. These override the database's scoring_config weights. |
| Verifier agreement | The confidence multiplier per agreement grade. An ingest always uses single. |
A Setup incomplete box at the top lists anything the
setup check found: no file yet
(defaults are shown, and saving creates it), the unedited template, or no
calibration. Saving from this page counts as reviewing the template, even
with its values unchanged; calibration needs threatlens-calibrate.
Values are validated before saving, and saved changes apply to the next
ingest without a restart. Re-score existing indicators runs
rescore_from_db() on the active database. Changing weights does
not change stored scores until that runs or the next ingest does.
The weights here apply to every database the dashboard or ingest touches. Test weights left in place will be applied to the next evaluation run, which is what happened to ex3 and ex4 (see the evaluation reports).
/export) and the tabs' Export buttonsDownloads data from the active database (the header's Database dropdown). The Export page in the header walks through four choices, with a live count and the first rows as a preview:
| Data | Filters | Formats |
|---|---|---|
| Indicators: value, refanged value, quality score and grade, confidence, the four dimension scores, reports, engine, open flags and their types, STIX type, first/last seen (plus context and the full score breakdown, off by default) | value contains, type, minimum / maximum score, engine, seen in report, sort and order | CSV, JSON, STIX 2.1 |
| Reports: title, file, URL, published date, engine, model, reliability, indicator count, mean quality score, ingested (publisher, authors, SHA-256 and file path optional) | title / file / URL contains, engine | CSV, JSON |
| Quality flags: indicator, flag type, dimension, severity, status, detail, raised | status, flag type, severity, dimension, indicator ID | CSV, JSON |
| Sightings: indicator, report, seen, excerpt (offsets optional) | value contains, type, report ID | CSV, JSON |
Each column can be switched on or off. Every tab also has an
Export button with a format selector: it downloads exactly
what that tab is showing, on every page rather than just the visible one.
The Indicators button uses the tab's filters and sort order, so the file
matches the table row for row (the two share one query,
threatlens.db.queries).
=, +, - or @ is prefixed with an apostrophe: report text is untrusted, and a spreadsheet would otherwise run it as a formula.{"export": {…}, "items": […]}: the dataset, database, time, filters and columns, then the records, with nested fields kept as objects.identity for ThreatLens, one indicator per indicator with a STIX pattern for its type, and a report per source report that links to the indicators seen in it. Patterns use the refanged value (hxxp://evil[.]com becomes http://evil.com), because a defanged pattern matches nothing; the value as written is kept in the description. Identifiers are derived from the indicator's type and value, so exporting twice gives the same ids and an importing platform does not create duplicates. A screened indicator (confidence 0) is exported as unknown, not malicious-activity. A type with no STIX observable is left out, and the download reports how many were.Files are named after the database, dataset and time, e.g.
ex4_threatlens_indicators_20261018-0930.stix.json. The same
exports are available from Python: threatlens.exporters.export(session,
"indicators", "csv", filters={...}).
/db-browser/) and documentation (/docs/)The header's Database link opens Datasette, embedded in the
dashboard, over the active database. It shows every table's raw rows and
columns, unlike the curated tabs, and is read-only. It follows the database
dropdown. Documentation serves these pages from
src/threatlens/docs/, which ships with the package.
| Action | Effect |
|---|---|
| Edit an indicator | Writes the fields as entered (type, value, normalised, confidence, quality score, first/last seen, context JSON). This bypasses the ingest validation in conformance.py; only the database's CHECK constraints apply, so a bad score or invalid JSON is refused but any ioc_type is accepted. An edited quality score is overwritten by the next rescore; an edited confidence stays. |
| Delete an indicator | Removes it with its sightings and quality flags |
| Delete a report | Removes the source, its raw document and its sightings. Indicators seen only in that report stay, with no sightings. The PDF file stays on disk. Their first/last seen dates are re-derived from the reports that remain. |
| Resolve / dismiss a flag | Sets quality_flags.status. Nothing else changes; the score is not affected. |
Every page is static HTML plus a JavaScript module calling a JSON API.
FastAPI's interactive API docs are at /api/docs and /api/redoc.
| Router | Endpoints |
|---|---|
indicators.py | GET /api/indicators (filters: ioc_type, search, min_score, max_score, engine, limit ≤ 1000, offset, sort, order) · GET / PATCH / DELETE /api/indicators/{id} |
sources.py | GET /api/sources · GET / DELETE /api/sources/{id} · GET …/{id}/pdf, …/pdf/highlights, …/pdf/pages/{n}.png?scale= |
quality_flags.py | GET /api/quality-flags?status= · PATCH /api/quality-flags/{id} (any status text is accepted) |
stats.py | GET /api/stats: counts, open flags, mean score, indicators by type |
db_admin.py | GET /api/db/files · POST /api/db/select |
report_files.py | GET / POST /api/report-files · GET / DELETE /api/report-files/{filename} |
ingest.py | GET /api/ingest/pending, /engines, /jobs, /jobs/{id}/log?offset=, /downloads · POST /api/ingest/queue, /url, /jobs/{id}/cancel, /cancel-all, /clear, /downloads/{id}/cancel, /downloads/clear |
export.py | GET /api/export (datasets, columns, filters, formats) · /api/export/{dataset}/preview · /api/export/{dataset}?format=csv|json|stix&columns=…&filters |
config.py | GET / PUT /api/config · POST /api/config/ollama-models, /api/config/rescore |
GET /api/config includes a setup object
(exists, edited, calibrated,
ready, warnings), and GET /api/ingest/engines
includes setup_warnings; the Configuration and Ingest pages show
them.
A middleware makes browsers revalidate the pages and their JS/CSS on every load, so a code change shows up without a hard refresh.
src/threatlens/dashboard/server.py: the FastAPI app, page routes, mounts (/static, /docs, /db-browser), the threatlens-dashboard entry pointsrc/threatlens/dashboard/routes/: the eight API routers abovesrc/threatlens/dashboard/db.py: engine/session wiring and runtime database switchingsrc/threatlens/dashboard/ingest_jobs.py, ingest_worker.py: the ingest queue and the pipeline worker (threatlens-ingest)src/threatlens/dashboard/url_downloads.py: URL downloads and their network safeguardssrc/threatlens/exporters/: the export datasets and the CSV, JSON and STIX 2.1 writers (stix.py), used by routes/export.py; src/threatlens/db/queries.py: the indicator filters and sorting shared with the Indicators tabsrc/threatlens/dashboard/pdf_view.py: PDF page rendering and indicator highlightingsrc/threatlens/dashboard/db_browser.py: the embedded Datasette browsersrc/threatlens/dashboard/*.html, static/js/, static/css/: the pages and their per-page scripts (package data, listed in pyproject.toml)src/threatlens/docs/: these documentation pages, served at /docs/ (package data)start.* / stop.*: background start and stop for bash, cmd and PowerShell (checkout only, not packaged)