FHOPS Web UI
============

The web UI is an optional browser surface over the artefacts the FHOPS CLI writes. It follows the
WS4 web UI design: FastAPI + uvicorn behind an optional extra, a vanilla ES module SPA, MapLibre
with an SVG fallback, JSON skin manifests, SSE for live runs, and loopback-only defaults.

The same application covers three planning lenses:

- **Shift-level operational:** machine/crew day-shift Gantt, assignments, playback, production,
  utilisation, mobilisation, and sequencing.
- **Ghotb log hauling:** Phase I flows and truckloads, Phase II/III truck and loader schedules, yard
  inventory, contractor workload, and haul KPIs.
- **TOPM-style tactical–operational:** period harvest decisions, production, product flows,
  facility demand and inventory, roads, silviculture, and fleet.

P12.1 ships the service skeleton, run discovery, the `run.json` manifest, and a minimal run browser.
P12.2 adds the read-only artefact API for all three domains, the navigable SPA shell, and the skin
loader. P12.3 adds the map, layer tree, and day/shift/period control (MapLibre with an SVG
fallback). P12.4 adds the domain panels: operational shift Gantt, hauling truck/loader Gantt and
charts, tactical period Gantt, KPI cards, report builder, and run comparison. P12.5 adds live
operational, hauling, and tactical runs, SSE progress, and pause/resume/stop control. P12.6 adds
validated scenario editing, the optional assistant, packaging and security hardening, and the phase
close-out.

Shell and navigation
--------------------

The SPA uses four primary workspaces: **Map**, **Plan**, **Results**, and **Data**. A skin selects
which workspaces and sub-panels are available; the sidebar shows only the panels in the current
workspace. Typical panels are:

- Map: Map, Legend
- Plan: Schedule, Haul, Tactical, Scenario, Run
- Results: KPIs, Reports, Compare
- Data: LiDAR

The selected skin, run, workspace, and panel are reflected in the URL
(``?skin=&run=&view=&panel=``), so reload, bookmark, and browser Back/Forward restore the view. The
workspace and panel tabs use ``role=tab``/``aria-selected``/``aria-controls``; Left/Right/Up/Down and
Home/End move between tabs. The shell targets desktop and iPad-landscape widths (1024 px and up); the
header and sidebar tighten below 1200 px, and the page does not scroll horizontally. A loading state
covers the initial app load, panel transitions, and map fetch instead of showing a blank canvas.

Tables use a shared sortable table shell with empty states, row counts, and CSV export. Map layers use
a colour-blind-aware palette; clicking a block highlights it in both the SVG and MapLibre renderers
and opens its attribute detail.

Install and run
---------------

.. code-block:: bash

   pip install 'fhops[web]'
   fhops web --runs runs/
   # open http://127.0.0.1:8765/

The default bind is loopback. A non-loopback bind is refused unless ``--allow-remote`` is passed;
the service has no authentication in this release.

Useful options:

.. code-block:: bash

   fhops web --runs runs/ --runs examples/ --port 9000
   fhops web --runs runs/ --root-path /user/me/proxy/8765
   fhops web --runs runs/ --geometry examples/hauling/blocks.geojson --geometry-crs EPSG:3005
   fhops web --runs runs/ --workspace /path/to/fhops-checkout
   fhops web --runs runs/ --allow-agent --agent-timeout 120
   fhops web --runs runs/ --lidar-bundles /path/to/femic/bundles

``--root-path`` makes the SPA and API prefix-relative, so a notebook proxy can publish the service
under a subpath. ``--workspace`` is the working directory for CLI subprocesses and the allowed root
for scenario paths; it defaults to the directory where ``fhops web`` was started.

For live SSE through a notebook or reverse proxy, allow ``text/event-stream`` and disable response
buffering (for example ``X-Accel-Buffering: no``, which the service already sends). If the proxy
still holds the stream, the Run panel's status polling and the finished-run 204 path recover the
state; ``--root-path`` keeps every API and static URL under the proxy prefix.

Run discovery
-------------

The service scans each configured root, including immediate child and grandchild directories. A
directory is a run when it contains ``run.json`` or a known artefact. The three domains are
classified from the files:

- operational: assignment CSVs, telemetry JSONL, playback day/shift summaries, rolling iterations;
- hauling: ``haul_flows.csv``, ``haul_truckloads.csv``, ``haul_schedule.csv``, ``haul_summary.json``,
  and the other ``haul_*`` files;
- tactical–operational: ``harvest_decisions.csv``, ``production.csv``, ``flows.csv``,
  ``purchases.csv``, ``inventory.csv``, ``roads.csv``, ``silviculture.csv``, ``fleet.csv``.

The API/CLI report unclassified or multi-domain runs with a warning rather than silently mixing
domains.

Run manifest
------------

``run.json`` is a small additive manifest:

.. code-block:: json

   {
     "schema_version": "1.0.0",
     "run_id": "run-a",
     "domain": "hauling",
     "run_type": "haul_solve",
     "status": "optimal",
     "command": "fhops haul solve examples/hauling/scenario.yaml --out ...",
     "scenario": "examples/hauling/scenario.yaml",
     "solver": "mip",
     "seed": 42,
     "files": {
       "haul_flows": "haul_flows.csv",
       "haul_schedule": "haul_schedule.csv",
       "haul_summary": "haul_summary.json"
     },
     "warnings": [],
     "generated_at": "2026-10-25T..."
   }

The `fhops haul plan`, `fhops haul schedule`, and `fhops haul solve` commands write one
automatically. It is optional for old runs. Write or refresh one from a run directory:

.. code-block:: bash

   fhops web manifest runs/haul-solve --status optimal
   fhops web discover runs/

The service reads ``run.json`` when present and otherwise classifies from files. When no manifest
status is available it reads the status from a summary artefact; it never invents one.

Service surface
---------------

The service API provides:

- ``GET /api/health``
- ``GET /api/runs`` and ``GET /api/runs/{run_id}``
- ``POST /api/runs``, ``GET /api/runs/{run_id}/status``, ``POST /api/runs/{run_id}/control``
- ``GET /api/runs/{run_id}/events`` (SSE)
- ``GET /api/runs/{run_id}/lidar``
- ``GET /api/lidar/bundles``
- ``POST /api/runs/{run_id}/lidar/{validate,register,map}``
- ``GET /api/scenarios/{run_id}`` and ``PUT /api/scenarios/{run_id}/{objective,locks,hauling,tactical}``
- ``POST /api/agent`` (only when ``--allow-agent`` is set)
- ``GET /api/runs/{run_id}/summary`` and ``/kpis``
- ``GET /api/runs/{run_id}/assignments`` and ``/playback``
- ``GET /api/runs/{run_id}/hauling/{flows,truckloads,schedule,yard-inventory,contractor-workload,trucks,loaders}``
- ``GET /api/runs/{run_id}/tactical/{harvest,production,flows,inventory,purchases,roads,silviculture,fleet}``
- ``GET /api/runs/{run_id}/reports`` and ``/reports/{path}``
- ``GET /api/skins`` and ``/api/skins/{id}``
- ``GET /`` and ``/static/*``

Table endpoints return ``columns``, ``rows``, ``total``, and ``returned``. Filters are optional
query parameters; an unknown filter column is a 400, and a missing artefact is a 404 naming the
logical file. The SPA shell selects a skin and run, shows the skin's tabs, and renders the selected
domain table/KPI/report from the API. The Scenario tab round-trips edits through the existing
loader; the map, full Gantt, comparison, and live SSE read only the artefacts the CLI wrote.

Map and layers
--------------

The map panel reads:

- ``GET /api/runs/{id}/time`` – the domain's control values: days plus shifts for operational,
  days for hauling, periods for tactical.
- ``GET /api/runs/{id}/layers`` – every known layer with an ``available`` flag and a reason when it
  is not drawable.
- ``GET /api/runs/{id}/map?day=&period=&shift=&layers=`` – a GeoJSON FeatureCollection.

Block geometry is resolved in this order:

1. ``--geometry path/to/blocks.geojson`` passed to ``fhops web``;
2. the scenario's ``geo_block_path``, read through the run manifest's ``scenario`` path;
3. a ``blocks.geojson`` file inside the run directory.

MapLibre expects WGS84 longitude/latitude. The service transforms projected coordinates with
``pyproj`` before returning the FeatureCollection. The source CRS comes from ``--geometry-crs``, the
scenario ``geo_crs``, the GeoJSON ``crs`` member, or EPSG:4326 in that order. Invalid or
out-of-range coordinates are a named 400, not a MapLibre crash.

The first cut draws block geometry, operational treatments/machines, hauling block flow volumes, and
tactical harvested areas. Roads, mills/sort yards, haul routes, and tactical facilities are reported
as unavailable until a geometry source exists; the service never invents coordinates. The layer tree
shows those reasons.

The renderer is MapLibre GL JS with an OpenStreetMap raster basemap; SVG is the fallback and is
switchable in the panel. The time control is day/shift for operational and hauling and period for
tactical.

LiDAR block layers are added when the scenario has support rows: ``lidar_slope``,
``lidar_canopy``, and ``lidar_operability``. They join committed support values to the existing
block geometry; the map does not read source rasters. The evidence table and map layers are
read-only; the bundle workflow can write a validated pin or support rows through the standard
scenario writer.

The LiDAR panel also exposes a bundle workflow. ``--lidar-bundles`` allowlists roots containing
FEMIC bundle directories (each with ``manifest.json``). Validate and register run in the service;
map products runs ``fhops scenario lidar map`` as a subprocess, so the request process does not
import GeoPandas/rasterio. Mapping needs the optional ``fhops[lidar]`` environment. Bundle and
roads paths are constrained to the configured roots and scenario directory.

Domain panels
--------------

- **Operational:** the Schedule tab renders a machine × day/shift Gantt from the assignment CSV,
  followed by the assignment table. KPIs and Reports read the run's own payloads.
- **Hauling:** the Haul tab shows the run provenance, then a dataset chooser for flows, truckloads,
  the truck/loader schedule (Gantt by truck/minute), yard inventory (grouped bars), contractor
  workload (grouped bars), trucks, and loaders.
- **Tactical:** the Tactical tab shows a period Gantt of harvest decisions plus production, flows,
  inventory, purchases, roads, silviculture, and fleet tables.
- **KPIs:** numeric KPI cards plus the full key/value payload and the run summary.
- **Reports:** the file tree plus a report builder over the current domain's artefact tables.
  Every table has a client-side CSV download.
- **Compare:** two run selectors, summaries, and a KPI table with `B − A` deltas.
- **Run:** start a domain-appropriate CLI run, watch its objective trajectory, and pause, resume, or
  stop it.
- **LiDAR:** provenance card, bundle/product pins, block support coverage, and the advisory
  ground/cable/helicopter operability table. This is read-only evidence from the scenario's
  ``lidar_products``/``lidar_support`` sections; it does not run LiDAR discovery or mapping.

Live runs
---------

The service never imports the optimizer into the request process. ``POST /api/runs`` validates an
allowlisted domain/run type, resolves the scenario under ``--workspace``, creates a bounded output
directory under the first ``--runs`` root, writes a manifest, and starts the FHOPS CLI as a
subprocess:

.. code-block:: json

   {
     "domain": "operational",
     "run_type": "solve_heur",
     "scenario": "examples/tiny7/scenario.yaml",
     "options": {"iterations": 500, "seed": 42}
   }

Supported operational run types are ``solve_heur``, ``solve_ils``, ``solve_tabu``,
``solve_mip_operational``, ``eval_playback``, and ``plan_rolling``; hauling supports ``haul_plan``,
``haul_schedule``, and ``haul_solve``; tactical supports ``tactical_operational`` and
``tactical_report``. Playback and tactical report runs take ``options.assignments`` and
``options.result_json`` respectively, resolved under ``--workspace`` or the run root. An unknown
type, a missing scenario/input, or a path outside those roots is a 400.

``GET /api/runs/{id}/status`` reports the process state. ``POST /api/runs/{id}/control`` accepts
``{"action": "pause"}``, ``"resume"``, or ``"stop"``; pause/resume use ``SIGSTOP``/``SIGCONT`` on
POSIX systems and stop terminates the subprocess. A CLI crash cannot take the API down.

``GET /api/runs/{id}/events`` is a named-event SSE stream. Live events use the run's own telemetry:
``progress`` carries ``iteration``/``step``, ``objective``, ``best``, and temperature when written;
``kpis`` carries the run-level metrics; ``assignment`` carries the final operational assignment row;
rolling-horizon ``progress`` adds a ``window`` object with ``start_day``/``end_day``; every event
carries its ``domain``. ``done`` carries the final status, summary path, and summary detail;
``error`` carries the tail of stderr. The
stream is throttled to at most 2 Hz, closes after ten minutes so EventSource reconnects, and returns
**204** for a finished run rather than a short stream. The Run panel starts the same allowlisted run
types and falls back to status polling when EventSource is unavailable.

Scenario editing
----------------

The Scenario tab uses a run id to resolve the scenario recorded in ``run.json``. The path must be
under ``--workspace`` or one of the ``--runs`` roots. The service edits YAML in place for operational
objective weights and schedule locks, and for inline hauling driver policy/contractor preferences.
A temporary file is loaded with ``load_scenario`` before the original is replaced, so an invalid
weight, lock, or contractor value is refused with the loader's own message and the original file is
left untouched. Top-level sections are replaced in place, which preserves comments elsewhere in the
scenario; comments inside the edited section may be normalised.

Tactical demand envelopes are different: the service validates a sparse overlay with
``load_tactical_overlay_scenario`` and writes both the overlay and a derived merged scenario under
the first ``--runs`` root (``_scenarios/``). The base tactical bundle is never rewritten. The
Scenario tab's **Use derived scenario in Run** button puts the derived path into the Run tab.

Skins
-----

Skins are JSON manifests in ``src/fhops/web/skins/``. The loader validates every panel, layer, and
action against a registry; an unknown name is refused with the name. ``read_only`` removes every
mutating action. The shipped skins are:

- ``operational-planner`` – schedule, objective/lock editing, run control, KPIs, reports.
- ``hauling-planner`` – hauling flows/schedule/inventory, driver/contractor editing, run control,
  KPIs.
- ``tactical-planner`` – tactical decisions/flows/inventory, demand-envelope overlays, run control,
  KPIs, LiDAR.
- ``reviewer`` – read-only access to schedule/haul/tactical, KPIs, reports, comparison, and LiDAR;
  it has no Scenario or Run tab and no mutating action.

The Scenario tab is hidden from the reviewer skin and from any manifest with ``read_only: true``.

Assistant
---------

The lower-left assistant is hidden unless the service is started with ``--allow-agent``. It sends a
text prompt to the installed ``opencode`` CLI as a subprocess in ``--workspace``; provider keys stay
with that CLI. The assistant has the service user's shell permissions, so enable it only on a
workstation you control. ``--agent-timeout`` bounds a request. Without the flag, ``POST /api/agent``
is a 403 and the UI does not show the panel.

Security and limits
-------------------

- Loopback only by default; ``--allow-remote`` is an explicit operator decision.
- No authentication in this release.
- Run roots are allowlisted; the service does not serve arbitrary paths.
- Scenario paths for live runs must resolve under ``--workspace`` or a run root; output directories
  are created under the first run root, and run types are allowlisted.
- LiDAR bundle paths must resolve under ``--lidar-bundles`` roots; mapping runs as a bounded CLI
  subprocess and writes only through the validated scenario writer.
- Scenario editing resolves through the run manifest and rejects paths outside those roots.
- The optional assistant is off by default, uses no shell, and inherits the service user's CLI
  permissions when enabled.
- The service is a reader. It does not import the optimizer into the request process; live runs are
  CLI subprocesses. The LiDAR panel reads the scenario's committed support rows and does not
  discover, download, or process source rasters.
- The assistant is opt-in and local-only; it is not a substitute for authentication.
