Skip to content

ADR 0015: One canonical resource address across the gateway apps; the hierarchy tree becomes a shared component

Status: Accepted Date: 2026-07-03 Issue: #718

Context

The gateway serves three single-page sub-apps — System (/system), HMI (/hmi), Data (/data), all hash-routed (internal/gateway/routes.go, pages.go). Today they speak three incompatible hash grammars:

  • System/Data (js/nav.js syncHash/restoreFromHash): view-first, #/{view}/… — e.g. #/site/{site}/{group}/{subTab}/d/{Type}/{name}, #/cmdata/{site}/{cm}, #/prodinfo/{subTab}.
  • HMI process navigation (js/hmi/hmi-nav.js): site-first, #/{site}/unit/{name}, #/{site}/cm/{name}, #/{site}/area/{name}, #/{site}/pc/{name} — segment 0 is a site name, not a view.
  • HMI dashboards (js/hmi/hmi-app.js): bare #batch, #alarms (pinned by smoke.spec.ts), plus a special-cased deep form that round-trips through the shared restoreFromHash.

Consequences, mapped in the #718 investigation:

  • One resource, three addresses. A Unit is #/site/S/equipment/units/d/Unit/N in System, #/S/unit/N in HMI, and #/unitdata/S/N in Data. Deep links don't transfer between apps; #690 shipped point bridges (app-aware search routing in js/search.js, a System deep-link fallback, an /hmi#alarms badge link) that each re-implement address translation by hand.
  • No hash builder exists. Hash strings are concatenated inline in nav.js, hmi-nav.js, hmi-display.js, and rebuilt independently in search.js from the route constants in js/state.js (TAB_GROUPS, TYPE_TO_SUBTAB, …).
  • Legacy redirects are handled in three placesrestoreFromHash, navigate(), and the refreshView switch each re-coerce historian/audit/genealogy/simulation and the old recipe-template views.
  • ~90 % duplicated tree logic. renderSidebarHierarchy (nav.js) and HMINav.renderTree (hmi-nav.js) build the same Site→Area→ProcessCell→Unit→ControlModule HTML from the same /api/v1/sites + /overview endpoints with separate caches, differing only in attribute wiring, HMI's live-state badges, and click targets.
  • Fragile cross-app links. historian.js links to /system#diagnostics (bare form), which restoreFromHash cannot parse — it works only because diagnostics happens to be the default view. Any other bare cross-app link would silently no-op.

The structural fix (#690 item 6) is one address per resource, resolved per-app, and one tree implementation.

Decision

Introduce a canonical resource-address grammar — #/r/{Kind}[/{site}]/{name} — parsed by all three apps and resolved through a per-app renderer table in a new shared route module, and extract the duplicated sidebar hierarchy into a shared DcsTree component whose selection emits canonical addresses.

Concretely:

  1. Grammar. #/r/{Kind}/{site}/{name} for site-scoped kinds, #/r/{Kind}/{name} for cluster-scoped ones (arity resolved by the kind's scope, known to the route table). {Kind} is the CRD Kind verbatim (Unit, ControlModule, Batch, …), matching the existing d/{Type}/{name} segments and the TYPE_TO_SUBTAB keys — no second naming scheme. Optional trailing segments (#/r/{Kind}/{site}/{name}/{facet}) are reserved for sub-views but not built now.

  2. One shared route module, js/routes.js, loaded by all three apps: buildResourceHash(kind, site, name), parseHash(hash) (which also normalizes #view#/view, fixing the bare-#diagnostics class of bug), the kind→scope table, and the single legacy-normalization table — the seven nav.js redirect families plus HMI's site-first forms all map to canonical or current-view hashes in one place, consulted once at each app's hash-entry point. The triple handling in restoreFromHash/navigate/refreshView is deleted. routes.js also owns currentApp(), today duplicated in search.js and nav.js.

  3. Per-app resolution. Each app registers a renderer per kind:

  4. System renders every kind — #/r/… resolves to today's detail views (group/sub-tab derived from TYPE_TO_SUBTAB, exactly as searchSystemHash already does). Amended by #1309: every kind except the batch execution kinds — only the HMI shell ships js/views/batch.js (a boundary set by the shell split, which put batch execution in the operator surface), so the System route table no longer claims those kinds.
  5. HMI maps the physical hierarchy to process-display semantics: Site/Area/ProcessCell/Unit → the corresponding process display; ControlModule → parent unit's display with the CM faceplate opened; Batch → the batch dashboard focused on that batch.
  6. Data maps ControlModule → cmdata, Unit → unitdata, Site → prodinfo.
  7. Fallback rule: a kind the current app has no renderer for navigates to /system + the same hash (full page). This generalizes #690's hand-built System fallback into table-driven behavior. Amended by #1309: the fallback target is per-kind (DcsRoutes.fallbackApp) — /hmi for batch execution kinds, which the System shell cannot render, /system for everything else.

  8. Canonical is what gets emitted and what stays in the address bar. The shared tree, global search, breadcrumbs, and cross-app links emit canonical addresses via buildResourceHash; when the user is on a resource-centric state, syncHash writes the canonical form, so a copied URL works in any app. Old resource forms (…/d/{Type}/{name}, #/{site}/unit/{name}, #/cmdata/…, #/unitdata/…) remain parseable indefinitely via the legacy table — no bookmark breaks. One exception: an open Alarm detail keeps the native #/alarms/{site}/{subTab}/d/… form, because the alarm sub-tab (active/all/shelved) is a list filter that is not derivable from the resource kind and the canonical address has no slot for it — rewriting the bar would silently restore a refreshed or copied URL under the Active filter (#735). Inbound #/r/Alarm/… addresses still resolve, defaulting to the Active list.

  9. App views stay per-app. Dashboards, editors, and tab states (#/diagnostics, #/changerequests, #/prodinfo/{subTab}, #/sfceditor/…, HMI #batch/#alarms) are presentation state, not resource identity — they keep the #/{view}/… grammar (bare #batch stays as a pinned alias). Only resources get canonical addresses.

  10. Shared tree component: DcsTree in js/tree.js (following the DcsModal precedent, #704). One data source and one cache (/api/v1/sites + per-site /overview), data-action event delegation (the nav.js convention; HMI drops its bespoke listeners), and a decorate(node) hook for HMI's live-state badges. Because selection just sets location.hash = buildResourceHash(…) and resolution is per-app, the two trees' divergent click semantics collapse into the route table — the component itself is app-agnostic.

Alternatives Considered

  • Keep the #690 bridges and add more as needed. Rejected: every new resource kind or app surface multiplies hand-written translation code in search.js-style branches, and copied URLs still don't transfer — the defect this ADR exists to remove.

  • History-API path routing (/system/unit/{site}/{name} as real paths, pushState, server catch-all per app). Cleaner URLs, but requires wildcard routes and SPA-fallback handling in the gateway (routes.go/pages.go), interacts with auth redirects and the file-backend 302s (#456), and invalidates every existing bookmark form at once. Hash routing needs zero server changes; not worth the blast radius for an internal tool's URL aesthetics.

  • Merge the three sub-apps into one SPA. Eliminates cross-app linking entirely, but the /system/hmi/data split is load-bearing: role-based landing and permission gating key off the path (js/auth.js), and the operator HMI is deliberately a separate, smaller surface from the engineering app. Rejected.

  • Declare the System URL canonical everywhere (all resource links go to /system#…). Simplest possible scheme, but an operator clicking a unit in HMI search would be bounced into the engineering app — it abandons per-app rendering, which is half the requirement.

  • Lowercase short-form kind tokens (#/r/unit/…, #/r/cm/…, as HMI uses today). Friendlier to type, but introduces a second kind-naming scheme needing its own mapping table and collision management alongside TYPE_TO_SUBTAB. CRD Kinds verbatim keep one vocabulary; the HMI short forms live on only as legacy-parse entries.

Consequences

  • Code that moves: new js/routes.js and js/tree.js; js/nav.js sheds tree rendering and the duplicated redirect handling, gains canonical parse/emit; js/hmi/hmi-nav.js shrinks to display dispatch plus a DcsTree config (also deleting the dead HMIState.selectedSite branch in hmi-app.js); js/search.js drops its three-way app branch and emits canonical hashes; all three index.html load the new modules. docs/api-reference.md is unaffected (no HTTP routes change).

  • Tests: routes.js is DOM-free — unit-test the grammar, legacy table, and per-app resolution under make test-js. A new Playwright spec pins cross-app deep-link transfer (open a canonical Unit address in all three apps). Existing pins (smoke.spec.ts bare #batch) keep passing by construction.

  • Staged implementation (each stage shippable):

  • routes.js + canonical parsing in all three apps + legacy-table consolidation;
  • emitters flip — search, cross-app links, breadcrumbs build canonical addresses;
  • DcsTree extraction, HMI and System/Data both consuming it;
  • syncHash writes canonical form for resource-centric states.

  • Operational/compliance: none — URLs are client-side fragments; no API, CRD, or audit surface changes.

  • Reversibility: high through stage 2 (additive parsing; old forms still emitted). After stage 4, reverting emission is mechanical, but canonical URLs will exist in users' bookmarks — the legacy table would need an inverse entry. The legacy-normalization table is permanent either way; that is the accepted cost of never breaking a bookmark.