diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index 4c42eba..a9810e5 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -42,8 +42,17 @@ jobs: python -m coverage json python -m coverage xml - - name: Smoke-test CLI against worked example + - name: Smoke-test CLI and committed worked-example evidence + # --preflight, not by preference: the example's data/source and + # data/derived are gitignored (a county cadastral snapshot alone is + # 55 MB), so a fresh checkout has no outputs for the full artifact + # checks to read. What preflight does still see is the committed + # inputs, and `runs.present_files` fails here if pipeline.py has been + # edited without re-running -- the drift that shipped in #14. The + # complete artifact validation runs in example.yml, which regenerates + # the data first. run: | + python -m unittest -v tests.test_verify.CommittedWorkedExampleContractTests openmapstack validate examples/tartu-development/project.yaml --preflight openmapstack inspect examples/tartu-development/project.yaml --json > /tmp/openmapstack-inspection.json diff --git a/.github/workflows/example.yml b/.github/workflows/example.yml new file mode 100644 index 0000000..6afd1c4 --- /dev/null +++ b/.github/workflows/example.yml @@ -0,0 +1,83 @@ +name: Worked example (full validation) + +# The fixture job can only preflight the worked example: its sources and +# derived outputs are gitignored, so a fresh checkout has nothing for the +# artifact checks to read. This job regenerates them from the real Estonian +# services and then runs the validation the fixture job cannot -- including +# `openmapstack verify`, which is the only thing that compares project.qgz +# against what the manifest claims. +# +# It reaches three external services (Maa- ja Ruumiamet S3, the ETAK WFS, and +# Tartu's ArcGIS Feature Services), so it is deliberately not on every PR: +# an outage upstream must not redden unrelated work. + +on: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + pull_request: + paths: + - "examples/tartu-development/**" + - "openmapstack/**" + - ".github/workflows/example.yml" + +jobs: + worked-example: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install the CLI with geodata support + run: | + pip install ".[geo]" + pip install pyproj + + - name: Regenerate the example from its real sources + working-directory: examples/tartu-development + run: python pipeline.py + + - name: Validate every artifact, not just the manifest + run: openmapstack validate examples/tartu-development/project.yaml + + - name: Verify the delivered product against the manifest + run: openmapstack verify examples/tartu-development + + - name: Prove every declared QGIS layer paints in the right place + run: | + docker run --rm \ + -e QT_QPA_PLATFORM=offscreen \ + -v "${PWD}:/workspace" \ + -w /workspace \ + qgis/qgis:3.44-trixie \ + python3 -m unittest -v \ + tests.evals.test_qgis_assertions.RuntimeLoadWithRealPyqgisTests.test_real_worked_example_every_declared_layer_renders + + - name: The committed QGIS project must be the one the pipeline writes + # project.qgz is generated deterministically and is committed, so a + # regenerated copy that differs means the repository ships a QGIS + # project its own pipeline no longer produces. Its legend carries + # facility counts, so an upstream change to Tartu's education data + # will trip this too -- also correctly: the committed example has then + # stopped describing the current sources and wants regenerating. + run: | + git diff --exit-code -- examples/tartu-development/project.qgz || { + echo "::error::The committed project.qgz differs from the one pipeline.py"\ + "just produced. Re-run examples/tartu-development/pipeline.py and"\ + "commit the regenerated project.qgz (and its run record)." + git diff --stat -- examples/tartu-development/project.qgz + exit 1 + } + + - name: Upload the regenerated run record + if: always() + uses: actions/upload-artifact@v4 + with: + name: worked-example-run + path: | + examples/tartu-development/runs/ + examples/tartu-development/validation/latest-report.json diff --git a/SKILL.md b/SKILL.md index 5b9ed94..1fb2a1e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -48,7 +48,7 @@ Hard rules for every material analysis — each is expanded in `references/proje * **The manifest must resolve.** Every step input is a source key or an earlier step's output, spelled as the producer declared it; every `generated_by` names a real step (`manifest_graph_resolves`). * **One canonical implementation creates every declared output.** Convenience/E2E entrypoints may wrap `pipeline.py` but must not duplicate its processing, QGIS, or report logic. * **Build a layer- and style-perfect QGIS project (`project.qgz`)** mirroring the web view: matching layer-tree groups, identical categorized styles, `./path.gpkg|layername=name` datasources, and a regional tiled basemap. **Success means valid layers, not exit code 0** — pin the runtime, and when PyQGIS is available require every layer `isValid()`; otherwise record `not_testable`, never an implicit pass. Two traps make a project that passes every one of those checks still show the wrong map, so check them explicitly: - * **Every layer declares its own ``, basemaps included.** A layer without one is assumed to be in the project CRS and never reprojected — a Web Mercator basemap in an EPSG:3301 project then renders ~1500 km from the data, under correctly placed analysis layers. Prefer building layers through the PyQGIS API, which resolves the provider's CRS for you; the trap is specific to hand-written `.qgs` XML. + * **Every layer declares a complete ``, basemaps included, and project reprojection is enabled.** A layer without one is assumed to be in the project CRS and never reprojected. An auth-id-only `` is also broken: QGIS can still report `EPSG:3301` while treating the CRS as invalid and silently painting nothing. Emit WKT or PROJ alongside the identifiers and set `SpatialRefSys/ProjectionsEnabled` to `1`. Prefer building layers through the PyQGIS API, which serializes these fields for you; the trap is specific to hand-written `.qgs` XML. * **A QGIS layer tree stacks the opposite way to a web map.** `presentation.map.layers` is ordered bottom-to-top, while a layer tree paints its *first* entry on top, so write the tree in reverse manifest order with the basemap last. Copying the manifest order verbatim puts opaque analysis fills over the point layers that belong above them, and the points vanish. * **Separate analysis semantics from rendering.** Declare semantic presentation roles; don't reinvent layout/colors/UX per run. * **Ship a reconfigurable view, and never let it misrepresent the run.** Organise the sidebar into tabs of collapsible sections, give every layer group an on/off control, and expose the analysis parameters and scenario overrides as live controls. Each control opens at the value declared in `presentation.controls` and returning there must reproduce the published numbers; any other position labels itself exploratory and offers a reset. The browser re-applies published rules to values the pipeline measured — it never measures geometry, and a control that changes a shape switches between buffers the pipeline materialised. @@ -116,7 +116,7 @@ For simple one-shot questions (single CRS conversion, one `ogr2ogr` invocation), * Hallucinating or fabricating mock coordinates and geometries instead of retrieving real source data (unless the user gave explicit, informed consent for a synthetic mock test) * Generating a QGIS project that lacks the web dashboard's layers, omits basemaps, or uses broken OGR datasource syntax (`path.gpkg|layer` without `layername=`), causing layers to load as non-spatial attribute tables -* Writing `.qgs` XML by hand with no `` on a tile basemap, or copying the manifest's layer order straight into the layer tree — both produce a project where every layer is valid and every datasource resolves, yet the map shows the wrong place or silently hides a layer +* Writing `.qgs` XML by hand with no ``, an auth-id-only CRS block, or no `ProjectionsEnabled`, or copying the manifest's layer order straight into the layer tree — these produce a project where every layer is valid and every datasource resolves, yet the map shows the wrong place or silently hides a layer * Producing Shapefile as new output (column truncation, 2GB limit, no UTF-8, multi-file) * Calling `.distance()`, `.buffer()`, or `.area` on geographic CRS (EPSG:4326) — degrees are not meters; unless specific tool explicitly supports wgs84 based geodesic calculations * Web Mercator (EPSG:3857) for area or distance calculations — it is not equal-area, and the units are not in meters except at the equator diff --git a/docs/maintainers/debugging.md b/docs/maintainers/debugging.md index 80da203..d650601 100644 --- a/docs/maintainers/debugging.md +++ b/docs/maintainers/debugging.md @@ -18,8 +18,6 @@ Before changing a checker because one layer is green and another red, confirm wh `openmapstack validate ... --preflight` deliberately skips checks that need produced artifacts, validation reports, and run records. It is useful before a project has run; it is not full health evidence. -**Current temporary debt (2026-09-02):** issue #14 tracks that `examples/tartu-development` fails its own full verification because of a stale run-record inventory and manifest/QGIS layer-group drift, while ordinary CI currently smoke-tests it with `--preflight`. Remove/update this note when #14 is resolved and protected by the appropriate CI path. - Useful comparison: ```bash @@ -51,7 +49,7 @@ Do not reintroduce hardcoded `geom` assumptions in new generic checks. A checker Two failures have already escaped simpler validity checks: -1. **Missing layer CRS in hand-written `.qgs` XML.** A Web-Mercator basemap with no `` may be interpreted in the project CRS and render ~1500 km from the analysis while every datasource still looks valid. Prefer the PyQGIS API where possible; it resolves provider CRS. Static checks require declared CRS for all layers. +1. **Missing or incomplete CRS in hand-written `.qgs` XML.** A Web-Mercator basemap with no `` may be interpreted in the project CRS and render ~1500 km from the analysis. An auth-id-only `` is just as dangerous: `authid()` looks correct while QGIS treats the CRS as invalid and silently cannot transform or paint the layer. Prefer the PyQGIS API where possible. Static checks require full WKT/PROJ definitions and `ProjectionsEnabled`; real-QGIS checks require each manifest layer to change the rendered pixels. 2. **Layer-tree ordering.** MapLibre/web layers are conventionally declared bottom-to-top, while QGIS paints the first tree entry on top. Copying manifest order verbatim can bury point/line layers under opaque polygons. The generated QGIS tree needs the appropriate reverse paint order. A nonblank render alone is insufficient. The visual suite includes layer-removal comparison and manifest reconciliation because a rendered image can be nonblank while a declared layer is absent. @@ -119,4 +117,4 @@ When a plausible wrong project from a live/user run survives the current checks, Retained live/visual evidence belongs under `evals/results//...` and CI artifacts. Do not treat generated result JSON, screenshots, event streams, or temporary projects as canonical repository state unless a fixture intentionally owns them. -The normalized `agent.json` is vendor-neutral audit data; raw provider events are diagnostics. Assistant final-answer prose is never the correctness oracle. \ No newline at end of file +The normalized `agent.json` is vendor-neutral audit data; raw provider events are diagnostics. Assistant final-answer prose is never the correctness oracle. diff --git a/evals/COVERAGE.md b/evals/COVERAGE.md index e932a22..97bf2ed 100644 --- a/evals/COVERAGE.md +++ b/evals/COVERAGE.md @@ -42,7 +42,7 @@ Legend: ✅ covered · ⚠️ partially covered · ❌ not covered (tracked belo | CRS/axis-order or output-metadata mismatch | 007 (real 3301 coordinates vs declared CRS cross-checked) | 914 `crs-metadata-mismatch` (relabelled output CRS) | | Wrong analysis CRS | 001 (analysis_crs enforced) | 902 `wrong-crs` | | Geographic CRS used for metric operations | every case (`geodata.crs_not_used_for_metrics`) | — | -| Basemap CRS declared (QGIS render lands on the data) | every visual-leg case | `qgis.every_layer_declares_crs` static gate | +| Complete QGIS layer CRS + project reprojection enabled | every visual-leg case | 922 `qgis-incomplete-crs` | ## Source @@ -78,6 +78,7 @@ Legend: ✅ covered · ⚠️ partially covered · ❌ not covered (tracked belo | Risk | Positive | Mutation | |---|---|---| | QGIS runtime load + non-blank render | 001, 006 (visual legs) | 910 `qgis-false-success` | +| Every declared QGIS layer changes rendered pixels | 001, 006 (visual legs) + worked-example CI | 922 `qgis-incomplete-crs` (static twin) | | Manifest↔QGIS layer/CRS reconciliation | 001, 006 | — | | Interactive basemap (tiles + attribution) | 001, 006 (MapLibre + OSM XYZ) | 913 `basemap-missing` | | Manifest claims visible in the product | 001, 006 | 912 `dashboard-silent-warnings` | diff --git a/evals/README.md b/evals/README.md index bf15e07..4717264 100644 --- a/evals/README.md +++ b/evals/README.md @@ -34,10 +34,10 @@ denominators separate: - `integration_visual`: rendered QGIS/browser integration checks. Cases 001–006 support both fixture and live execution (001 and 006 also run a -visual leg). Cases 901–911 are fixture-only mutations; cases 912 and 913 are -visual-only mutations, proving that a dashboard which hides a manifest warning -and one that ships no background map each fail in a real browser. Mutation -detection is never included in contract or agent pass rates. +visual leg). Cases 901–911 and 914–922 are fixture-only mutations; cases 912 +and 913 are visual-only mutations, proving that a dashboard which hides a +manifest warning and one that ships no background map each fail in a real +browser. Mutation detection is never included in contract or agent pass rates. Every score type also publishes a `capability` block — assertions evaluated, how many were `not_testable`, and how many soft gates went unmet — and the diff --git a/evals/cases/922-qgis-incomplete-crs/expected.yaml b/evals/cases/922-qgis-incomplete-crs/expected.yaml new file mode 100644 index 0000000..c8aaf8d --- /dev/null +++ b/evals/cases/922-qgis-incomplete-crs/expected.yaml @@ -0,0 +1,24 @@ +id: 922-qgis-incomplete-crs +case_type: mutation +modes: [fixture] +score_types: { fixture: mutation_tests } +project_dir: project +hard_gate: true +mutation: + control_generator: "{python} {evals_dir}/fixtures/reference_pipeline/gen.py {project_dir}" +fixture: + generator: "{python} {evals_dir}/fixtures/reference_pipeline/gen.py {project_dir} --break=qgis_incomplete_crs" + source_baseline: + - { source: ../../fixtures/mini-tartu/parcels.geojson, destination: data/source/parcels.geojson } + - { source: ../../fixtures/mini-tartu/roads.geojson, destination: data/source/roads.geojson } + - { source: ../../fixtures/mini-tartu/pois.geojson, destination: data/source/pois.geojson } + +assertions: + - assert: project.conforms_to_schema + - assert: validation.run_record_matches + - assert: qgis.static_valid + # An authority id is not a CRS definition. QGIS still reports authid(), + # yet cannot transform the layer and silently paints nothing. + - assert: qgis.every_layer_declares_crs + expect: failed + expect_code: layer_crs_incomplete diff --git a/evals/fixtures/reference_pipeline/gen.py b/evals/fixtures/reference_pipeline/gen.py index 88e0e31..f8d2d37 100755 --- a/evals/fixtures/reference_pipeline/gen.py +++ b/evals/fixtures/reference_pipeline/gen.py @@ -23,6 +23,7 @@ validation_laundering drop a required check from the report dashboard_only omit the declared canonical pipeline.py qgis_broken_datasource project.qgz references a missing file + qgis_incomplete_crs QGIS layers carry authid-only invalid CRS blocks incomplete_pagination roads source reports numberMatched > returned mutated_source pipeline rewrites its own copied "immutable" source file """ @@ -35,6 +36,7 @@ import json import os import platform +import re import shutil import sys import zipfile @@ -599,36 +601,46 @@ def _project_layers(output_dir: Path, project: dict) -> list[dict]: return [entry for entry in layers if (output_dir / entry["file"]).is_file()] -_EPSG_3301_SRS = """ - - PROJCS["EST97 / Estonia 1997",GEOGCS["EST97",DATUM["Estonia_1997",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY["EPSG","6180"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4180"]],PROJECTION["Lambert_Conformal_Conic_2SP"],PARAMETER["standard_parallel_1",59.33333333333334],PARAMETER["standard_parallel_2",58],PARAMETER["latitude_of_origin",57.51755393055556],PARAMETER["central_meridian",24],PARAMETER["false_easting",500000],PARAMETER["false_northing",1000000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Northing",NORTH],AXIS["Easting",EAST],AUTHORITY["EPSG","3301"]] - +proj=lcc +lat_0=57.5175539305556 +lon_0=24 +lat_1=59.3333333333333 +lat_2=58 +x_0=500000 +y_0=1000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs - 2417 - 3301 - EPSG:3301 - EST97 / Estonia 1997 - lcc - GRS80 - false - - """ +_EPSG_3301_SPATIALREFSYS = """ + PROJCS["Estonian Coordinate System of 1997",GEOGCS["EST97",DATUM["Estonia_1997",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],AUTHORITY["EPSG","6180"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4180"]],PROJECTION["Lambert_Conformal_Conic_2SP"],PARAMETER["latitude_of_origin",57.5175539305556],PARAMETER["central_meridian",24],PARAMETER["standard_parallel_1",59.3333333333333],PARAMETER["standard_parallel_2",58],PARAMETER["false_easting",500000],PARAMETER["false_northing",6375000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AUTHORITY["EPSG","3301"]] + +proj=lcc +lat_0=57.5175539305556 +lon_0=24 +lat_1=59.3333333333333 +lat_2=58 +x_0=500000 +y_0=6375000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs + 1259 + 3301 + EPSG:3301 + Estonian Coordinate System of 1997 + lcc + EPSG:7019 + false + """ # The tiled basemap is served in Web Mercator. Omitting this made QGIS # assume the project CRS for it and skip reprojection entirely, placing an # Estonian project's background map ~1500 km away in the Ardennes -- a -# confidently wrong map, which is worse than none. -_EPSG_3857_SRS = """ - - +proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs - 3857 - 3857 - EPSG:3857 - WGS 84 / Pseudo-Mercator - merc - EPSG:7030 - false - +# confidently wrong map, which is worse than none. Writing it *incompletely* +# is the quieter version of the same fault: a holding only +# / reads back as an invalid CRS that QGIS can build no +# transform from, so the layer loads, reports its authid, and paints nothing. +_EPSG_3857_SPATIALREFSYS = """ + PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Mercator_1SP"],PARAMETER["central_meridian",0],PARAMETER["scale_factor",1],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],EXTENSION["PROJ4","+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs"],AUTHORITY["EPSG","3857"]] + +proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs + 3857 + 3857 + EPSG:3857 + WGS 84 / Pseudo-Mercator + merc + EPSG:7030 + false + """ + + +_EPSG_3301_SRS = f""" +{_EPSG_3301_SPATIALREFSYS} + """ + + +_EPSG_3857_SRS = f""" +{_EPSG_3857_SPATIALREFSYS} """ @@ -755,15 +767,33 @@ def tree_layer(source: str, name: str, layer_id: str, checked: str = "Qt::Checke ''' ) - return ( + xml = ( '\n' '\n' '\n' ' \n' + f' \n{_EPSG_3301_SPATIALREFSYS}\n \n' f' {"".join(tree_parts)}\n' f' {"".join(layer_parts)}\n' + # Without ProjectionsEnabled QGIS discards on read, + # however complete it is, and opens a metric national-grid analysis + # in whatever CRS the reader's defaults supply. + ' \n' + ' 1\n' + ' \n' '\n' ) + if break_mode == "qgis_incomplete_crs": + # Preserve the reassuring authority labels while deleting the actual + # definitions. QGIS still reports authid(), but the CRS is invalid and + # layers in another CRS silently contribute no pixels. + xml = re.sub( + r']*)?>.*?([^<]+).*?', + r'\1', + xml, + flags=re.DOTALL, + ) + return xml def _copy_vendored_maplibre(output_dir: Path) -> None: diff --git a/examples/tartu-development/README.md b/examples/tartu-development/README.md index 80d8483..f47ccd1 100644 --- a/examples/tartu-development/README.md +++ b/examples/tartu-development/README.md @@ -84,10 +84,21 @@ tartu-development/ facility edits and drawn geometry are explicitly labelled map-only until the canonical pipeline recomputes spatial measurements. No browser draft changes source files, project validation, or the accepted run. -- **QGIS as a first-class view.** The generated project uses relative sources, - mirrored styles/layer groups, explicit scenario styling, three live basemaps, - and static archive/source/style validation. If PyQGIS is unavailable, runtime - loading is reported as `not_testable`, never passed implicitly. +- **QGIS as a first-class view.** `project.qgz` uses relative sources, explicit + scenario styling, live basemaps, and static archive/source/style validation. + Its layer tree is *generated from* `presentation.map.layer_groups` and + `presentation.map.layers`, so the desktop project cannot reorganise what the + manifest says the product contains: a group declared with no QGIS counterpart + fails the run rather than shipping. The three suitability tiers are three + layers filtered by OGR subsets, one per manifest group, mirroring the three + toggles the dashboard offers. If PyQGIS is unavailable, runtime loading is + reported as `not_testable`, never passed implicitly. +- **Two backgrounds, on purpose.** The dashboard loads CARTO Positron as a + MapLibre vector style. QGIS cannot read one, and CARTO's raster XYZ + equivalent now answers unauthenticated requests with an *API KEY REQUIRED* + watermark, so `project.qgz` carries the Maa- ja Ruumiamet Baaskaart WMS — + authoritative, key-free, and served natively in EPSG:3301 — with + OpenStreetMap XYZ as the unchecked alternative. ## Current regenerated result @@ -141,4 +152,30 @@ Outputs include: Set `OPENMAPSTACK_USE_QGIS_DOCKER=1` to request native project compilation with the pinned QGIS container. The deterministic XML generator remains the fallback; -runtime layer validity is still reported separately. +runtime layer validity is still reported separately. Both paths build the layer +tree from the same manifest-derived plan, so they cannot drift apart. + +Re-running is not optional after editing `pipeline.py`: the run record hashes +the pipeline alongside the sources, and `openmapstack validate --preflight` +fails `runs.present_files` while the committed record describes a version of +the code that is no longer there. + +## Checking it + +```bash +openmapstack validate examples/tartu-development/project.yaml # manifest + artifacts +openmapstack verify examples/tartu-development # oracle-free product QA +``` + +Both report `warning`, which is the honest status for this project: the +education source publishes no reuse license, and the 25-minute walking +criterion is met with a straight-line proxy rather than a pedestrian-network +isochrone. The QGIS checks need PyQGIS and report `not_testable` without it; +`.github/workflows/example.yml` regenerates the data and runs the full set. + +## Editing project.yaml + +`pipeline.py` rewrites `project.yaml` at the end of every run (`updated_at`, +per-source retrieval metadata, `runs.latest`) by dumping the parsed document, +which **discards YAML comments**. Prose that has to survive a run belongs in a +data field — `note`, `rationale`, `statement` — not in a `#` comment. diff --git a/examples/tartu-development/dashboard.html b/examples/tartu-development/dashboard.html index 0bfe0c4..42de569 100644 --- a/examples/tartu-development/dashboard.html +++ b/examples/tartu-development/dashboard.html @@ -581,7 +581,7 @@