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

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:

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:

{
  "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:

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:

{
  "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.