Skip to content

Modernize grafana plugin - #1

Merged
ngc7293 merged 11 commits into
masterfrom
modernize-grafana-plugin
Oct 6, 2026
Merged

ngc7293 merged 11 commits into
masterfrom
modernize-grafana-plugin

Conversation

@ngc7293

@ngc7293 ngc7293 commented Oct 6, 2026 •

Copy link
Copy Markdown
  • Fork Orchestra Cities' map panel, and upgrade it's dependencies and build system to the latest Grafana recommendations.
  • Switch default basemap to OSM and add API key input for CARTO
  • Update styling and interactions with data layers editor (but don't change the data layer options themselves)
  • Update licenses and references so they point to this repository

ngc7293 and others added 10 commits October 5, 2026 13:50
@grafana/toolkit was deprecated in Grafana 9 and removed in Grafana 10, and
it no longer installs on current Node (its eslint-plugin-jsdoc pin refuses
anything past Node 17). Replace it with the scaffolding that
@grafana/create-plugin 7.11 generates, via `create-plugin migrate`.

Runtime dependencies are deliberately untouched here: Grafana 9, React 17 and
OpenLayers 6 all stay put so this commit isolates the build-system change.

- Build: webpack 5 + SWC under .config/, replacing the toolkit's bundler.
- Lint: ESLint 9 flat config (@grafana/eslint-config). Autofixed `no-var`,
  and widened two useMemo dep lists that the new React Compiler rule
  rejected for depending on optional-chained subproperties.
- Test: jest 29 + @swc/jest. Inline snapshots re-recorded for jest 29's
  snapshot format (`Object {` -> `{`); values are unchanged. jest 29 cannot
  write inline snapshots through prettier 3, so prettierPath is disabled.
- Types: constrain ObservablePropsWrapper<T> to `T extends {}`, which TS 5.9
  requires to spread T into JSX. Matches the fix upstream in Grafana.
- Pin a single `ol` copy via npm overrides. @grafana/data ships its own
  exact-pinned ol and its exported layer types reference it, so a second copy
  makes every BaseLayer crossing the Grafana boundary unassignable.
- Package manager: yarn 1 -> npm, matching current scaffolding.
- CI: node 14 -> 22, refreshed actions, dropped the deprecated ::set-output
  calls, and replaced the hand-rolled release job with
  grafana/plugin-actions/build-plugin.
- Dev env: compose now builds Grafana from .config/Dockerfile and mounts
  dist/, keeping the CrateDB and TimescaleDB services. Data sources and
  dashboards are provisioned from provisioning/ instead of the curl script,
  so set-up-grafana.sh, data_sources/ and the stock grafana.ini are gone.
  The dashboards were stored as /api/dashboards/db payloads ({meta, dashboard});
  file provisioning wants the bare dashboard object, so they are unwrapped.
  The Postgres data sources move `database` into jsonData, where current
  Grafana reads it from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Targets Grafana 12.3+ (grafanaDependency). The frontend packages are pinned to
13.1.0, which is the pairing @grafana/create-plugin 7.11 scaffolds: @grafana/ui
13.2+ requires React 19, while 13.1 is the last line on React 18.

OpenLayers could not be upgraded separately: @grafana/data depends on `ol` and
its exported map-layer types reference it directly, so the plugin's `ol` has to
stay major-aligned with Grafana's or every BaseLayer crossing the boundary
becomes unassignable.

OpenLayers API changes:
- ol 8 reparameterised VectorSource by feature type rather than geometry type,
  so FrameVectorSource now extends VectorSource<Feature<T>>.
- ol/proj/Units lost its default export and became a string union. The value is
  only ever handed to ScaleLine, so it now uses ScaleLine's own Units type,
  which is what it should have been all along.
- Map.getViewport() and Style.getText() became nullable; MapBrowserEvent's type
  parameter is now constrained, so the pointermove listener uses the default.
- GeoJSON.readGeometry() is nullable; getGeometryFromGeoJSON handles that and no
  longer allocates a GeoJSON format object per row.
- ol-ext 4 moved FontSymbol.defs from the prototype to a static, so the marker
  icon picker reads it from the class now.

Grafana API changes:
- `Vector` is gone from @grafana/data, as fields are plain arrays now.
- Replaced the remaining field.values.get(i) calls with plain indexing. Grafana
  still patches Array.prototype with that accessor, but marks it as a
  Vector-migration aid only.

One latent bug surfaced along the way: the resource dimension assigned
`get: field.values.get`, passing the accessor unbound. Called as dim.get(i),
`this` was the dimension object rather than the array, so mapped resource
lookups returned undefined. This was equally broken under Grafana 9's
ArrayVector.

substr and onKeyPress were swapped for substring and onKeyDown. The
stylesFactory and config.theme deprecations are left as warnings; clearing them
means moving several class components to useStyles2.

Verified against Grafana 13.1.0 in Docker: the panel loads and OpenLayers
renders OSM tiles with the zoom, scale, attribution and layer-switcher controls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CARTO now requires an API key on every basemaps.cartocdn.com request. Without
one the CDN still answers 200, but the body is a placeholder tile stamped
"API KEY REQUIRED", which is what the example dashboards were rendering.

The key goes on CARTO's own registerOptionsUI as config.apiKey, so no
special-casing in LayerEditor is needed (unlike the nextzen layer, whose key is
hardcoded into the editor). Note the query parameter is `key`, not `api_key` as
nextzen uses: https://carto.com/basemaps/apikey

The example dashboards asked for the `default` base layer, which falls back to
CARTO, so all six map panels showed the watermark out of the box. They now ask
for osm-standard explicitly and render with no credentials.

The key is stored in the dashboard JSON and sent with every tile request, which
the field description says. That is inherent to a client-side basemap key and
matches how the nextzen key already works.

Verified against Grafana 13.1.0: CARTO tiles are requested as
.../{z}/{x}/{y}.png?key=<url-encoded key>, the field shows up under Base layer
when CARTO is selected, and the example dashboard now fetches OSM tiles only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Default base layer" resolved to CARTO, which now needs an API key on every
tile request. That layer exposes no options UI, so a new panel showed the
"API KEY REQUIRED" watermark with no way to fix it short of switching the
basemap to "CARTO reference map" and entering a key there. OSM needs no
credentials, so it is the better default.

This only changes the fallback. A geomapDefaultBaseLayerConfig set on the
Grafana server still wins, as before.

CARTO remains available by selecting it explicitly, where the API key field
added in the previous commit lives.

Verified against Grafana 13.1.0 with two panels side by side: a `default`
basemap draws OSM tiles, while an explicit CARTO basemap still requests
cartocdn.com with its ?key=.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Layer order sets render order on the map, but the only way to change it was to
delete and re-add layers. Each data layer now has a grip (Grafana's
`draggabledots` icon) that drags it to a new position. The grip is the only drag
handle, so clicking a section header still expands it and inputs inside the
layer editor behave normally. @hello-pangea/dnd also provides keyboard
reordering (Space to lift, arrows to move) with screen reader announcements.

The list now shows the topmost map layer first, as the core Geomap panel does.
`layers` stays in render order (index 0 drawn first) in the saved dashboard, so
the stored format is unchanged; moveByListPosition converts between the two.

@hello-pangea/dnd was already installed through @grafana/ui, which pins 18.0.1.
It is now declared directly at that exact version, matching what every Grafana
from 12.3 to 13.1 ships. Its redux and react-redux dependencies resolve to the
Grafana host's copies (both are webpack externals), so the bundle grows by
83 KiB rather than the library's full size.

Layers have no id and `name` is optional and not unique, so rows were keyed by
position (unkeyed fragments). That left each section's open state attached to a
position rather than a layer: delete or move a layer and the open section showed
a different one. The editor now keeps a key per layer in state, never saved.

Tests cover the position/render-order conversion and a keyboard reorder that
checks the open section moves with its layer. Swapping the keys back to
positional ones fails that test. The jest transform allow-list gains ol-ext and
the ESM-only packages that ol's `source` barrel pulls in, since this is the
first test to import a layer module.

Verified against Grafana 13.1.0 with a real mouse drag: the map's layer switcher
matches the new order, an expanded section stays open with its own settings
while dragged, clicking the grip does not toggle the section, and the saved
dashboard stores the new order with no extra fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With two queries and two marker layers, the second layer drew the first query
whatever was selected. Each marker layer draws only the first frame passing

    options.query.options === frame.refId || frame.meta

and the `|| frame.meta` half matches almost everything: SQL data sources put
meta (executedQueryString) on every frame. So the first query always won.
Heatmap, IDW and last-point layers had the same effect more directly by
hard-coding data.series[0].

All four now use getLayerFrame(): the selected query's frame, or nothing if
that query returned none (another query is never substituted), and the first
frame when no query is selected. The catch-all came from the fix for orchestracities#77, a
Merge transformation whose output matched no query. That still works: with no
query selected the merged frame is the first frame, and in Grafana 13 the merge
output (refId merge-A-B) can also be selected explicitly. getLayerFrame also
accepts the bare-string selection that dashboards saved before v1.4.5 store.

The tooltip crash ("can't access property 'state', u is undefined") was a
consequence. Hovering one of query A's points drawn by the query-B layer looked
up that layer's popup fields in query A's frame. The lookup pushed `undefined`
for each missing field, and getFieldDisplayName read `.state` from it. Missing
fields are now skipped, which also covers a field that was renamed or a query
that changed after the popup fields were picked.

The query selector's "recover a renamed query" logic only changed what it
displayed, never the saved value, and compared options by object identity. The
options are rebuilt on every refresh, so a failed or renamed query made the
selector show the first query instead. It now always shows the saved selection,
labelled "(no data)" when that query has no frame. Following renames
automatically was tried and dropped: the selector is unmounted while its layer
section is collapsed, so it would only follow renames sometimes.

Reproduced against Grafana 13.1.0 with a Postgres data source before the fix,
then verified after it: each layer draws its own query, hovering is error-free
and shows the right layer's fields, and the Merge case renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…query

Layers select their query by refId, so renaming a query left them pointing at a
refId that no longer exists: they drew nothing and the selector said
"(no data)". The panel now follows the rename and saves it through
onOptionsChange. That has to live in the panel: each layer's query selector is
unmounted while its section is collapsed, so it cannot see a rename reliably.

Each data update carries the queries that ran (data.request.targets).
findRenamedQueries treats a query as renamed when it sits at the same position
and nothing but its refId changed. That separates a rename from removing one
query and adding another, and from reordering queries, all of which also change
the refId at some position. A rename combined with other edits before the query
runs is not detected, and the selector still shows "(no data)" for it.
retargetRenamedQueries rewrites the affected layers, upgrading bare-string
selections from pre-v1.4.5 dashboards to a matcher. The work happens in
componentDidUpdate because onOptionsChange updates Grafana's state.

The layer editor's field pickers listed fields from every query. The location
pickers, popup title and popup time are Grafana FieldNamePickers, which offer
every field in context.data. The properties list only scoped itself when a
query was selected, and otherwise listed every query's fields, but only if the
first frame carried `meta` (the same catch-all as the layers had). All of them
now see only the frame the layer draws (getLayerFrame), so a layer cannot be set
up with another query's fields. The query selector still sees every query.
fillOptionsPaneItems takes a per-item context to allow that.

getSelectedRefId now holds the selected-query lookup, including the bare-string
form, for the layers, the selector and the rename handling.

Verified against Grafana 13.1.0 with two Postgres queries: renaming B to D with
the layer section collapsed keeps the layer drawing and the selector shows
"Query: D". The Italy layer (query A) offers lat, lon, city and the France layer
(query B) lat, lon, temperature. France's popup title correctly offers no
fields, since its query has no text field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Data sources with real credentials were being added to the tracked
provisioning/datasources/datasources.yaml, one `git commit -a` away from being
committed. Files matching provisioning/**/*.local.yaml (or .yml) are now
ignored. Grafana loads every YAML file in a provisioning directory, so such a
file works alongside the committed ones. CONTRIBUTING explains the convention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each layer row now matches a row of Grafana's query editor (QueryOperationRow,
which @grafana/ui does not export). The header has a collapse toggle, the
layer name, and the layer type in muted italics where a query shows its data
source. On the right are a delete button and the drag grip, and the layer's
options sit indented below. Sizes and colours were measured from a query row in
Grafana 13.1 and mapped to theme tokens, so the row also follows the light
theme. The header uses background.secondary, which is the lighter background
asked for.

The name is edited inline: click it (a pen appears on hover), then Enter or
blur saves and Escape cancels. Grafana's query rows save on Escape, but cancel
is the usual convention. An empty name clears it and shows "Unnamed layer".
Escape must not propagate: Grafana closes the panel editor on Escape unless an
input has focus, and the cancel handler has already blurred the input.

Deleting moved from a button inside the expanded options to the row header. Its
tooltip names the layer. IconButton uses the tooltip as its accessible name, so
a fixed "Remove layer" would announce every row the same way.

Each row owns its open and editing state, keyed by the editor's per-layer key
so the state stays with its layer through drags. The drag wiring stays in the
Draggable render callback, because the React Compiler lint rejects a component
reading a ref-holding object during render.

The rename box fills the space the name had rather than copying the query
editor's fixed 184px, which crowds the actions in the narrower options pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The "Base layer" and "Data layer" sections are now "Basemap" and "Data
  layers". The other user-facing uses follow suit: the "Default base layer"
  option is "Default basemap", and so is the message shown when the server
  admin fixes the basemap. Only display text changes; saved dashboards store
  the type id ("default"), not its name.
- The Name field in a data layer's options is gone. The name is edited inline in
  the layer's row, so it duplicated that.
- Add Layer sits below the list. The new layer goes at the bottom of the list,
  next to the button, rather than at the top where it could be off-screen. The
  bottom of the list is the start of render order, so a new layer is drawn
  beneath the existing ones until dragged up.
- The button is left-aligned, as Grafana's "Add query" is. Right-aligned, the
  new row slid in with its delete button under the pointer, so a double-click
  on Add Layer deleted the layer it had just added. Left-aligned, the second
  click lands on the new row's name and starts renaming it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ngc7293 ngc7293 self-assigned this Oct 6, 2026
This fork of the Orchestra Cities Map Panel now ships under its own identity.
The original project is unmaintained, and a plugin distributed under another
project's ID and name reads as theirs, or as endorsed by them. Grafana plugin
IDs are <organisation>-<name>-<type>. The old ID also collides with the
original's 1.4.4 entry in Grafana's plugin catalog.

- Plugin ID: orchestracities-map-panel -> transitapp-map-panel. This is a
  breaking change, hence 2.0.0. Panel options are unchanged; a dashboard
  migrates by changing each map panel's "type". Checked against Grafana 13.1: a
  panel still on the old ID stays blank with "Plugin orchestracities-map-panel
  not found", and the same JSON with only the type replaced renders.
- Display name "Multi-Layer Map", author TransitApp, links to the fork.
- Credit: plugin.json, package.json, README and CHANGELOG all name the original
  project. The original author is listed in package.json contributors, and
  plugin.json links to the original repo.
- README: a fork notice with credit and the date modifications began (AGPL-3.0
  section 5(a) requires a dated notice of modification), a maintenance-status
  note, the migration steps, screenshots loaded from the fork instead of the
  original repo, and the correct minimum Grafana version (12.3, was 8.2).
- The ID is also renamed in the Docker dev setup, the example dashboards and
  .config/docker-compose-base.yaml. That file is generated from plugin.json's id,
  and create-plugin's update does not regenerate it on the same version.

The sample data in db-init/ still names its original sources and is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ngc7293
ngc7293 force-pushed the modernize-grafana-plugin branch from 05aef07 to f379227 Compare October 6, 2026 13:47
@ngc7293
ngc7293 merged commit f6c92e4 into master Oct 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants