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.jssyncHash/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 bysmoke.spec.ts), plus a special-cased deep form that round-trips through the sharedrestoreFromHash.
Consequences, mapped in the #718 investigation:
- One resource, three addresses. A Unit is
#/site/S/equipment/units/d/Unit/Nin System,#/S/unit/Nin HMI, and#/unitdata/S/Nin Data. Deep links don't transfer between apps; #690 shipped point bridges (app-aware search routing injs/search.js, a System deep-link fallback, an/hmi#alarmsbadge 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 insearch.jsfrom the route constants injs/state.js(TAB_GROUPS,TYPE_TO_SUBTAB, …). - Legacy redirects are handled in three places —
restoreFromHash,navigate(), and therefreshViewswitch each re-coercehistorian/audit/genealogy/simulationand the old recipe-template views. - ~90 % duplicated tree logic.
renderSidebarHierarchy(nav.js) andHMINav.renderTree(hmi-nav.js) build the same Site→Area→ProcessCell→Unit→ControlModule HTML from the same/api/v1/sites+/overviewendpoints with separate caches, differing only in attribute wiring, HMI's live-state badges, and click targets. - Fragile cross-app links.
historian.jslinks to/system#diagnostics(bare form), whichrestoreFromHashcannot parse — it works only becausediagnosticshappens 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:
-
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 existingd/{Type}/{name}segments and theTYPE_TO_SUBTABkeys — no second naming scheme. Optional trailing segments (#/r/{Kind}/{site}/{name}/{facet}) are reserved for sub-views but not built now. -
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-#diagnosticsclass of bug), the kind→scope table, and the single legacy-normalization table — the sevennav.jsredirect 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 inrestoreFromHash/navigate/refreshViewis deleted.routes.jsalso ownscurrentApp(), today duplicated insearch.jsandnav.js. -
Per-app resolution. Each app registers a renderer per kind:
- System renders every kind —
#/r/…resolves to today's detail views (group/sub-tab derived fromTYPE_TO_SUBTAB, exactly assearchSystemHashalready does). Amended by #1309: every kind except the batch execution kinds — only the HMI shell shipsjs/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. - 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.
- Data maps ControlModule →
cmdata, Unit →unitdata, Site →prodinfo. -
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) —/hmifor batch execution kinds, which the System shell cannot render,/systemfor everything else. -
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,syncHashwrites 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. -
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#batchstays as a pinned alias). Only resources get canonical addresses. -
Shared tree component:
DcsTreeinjs/tree.js(following theDcsModalprecedent, #704). One data source and one cache (/api/v1/sites+ per-site/overview),data-actionevent delegation (thenav.jsconvention; HMI drops its bespoke listeners), and adecorate(node)hook for HMI's live-state badges. Because selection just setslocation.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–/datasplit 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 alongsideTYPE_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.jsandjs/tree.js;js/nav.jssheds tree rendering and the duplicated redirect handling, gains canonical parse/emit;js/hmi/hmi-nav.jsshrinks to display dispatch plus aDcsTreeconfig (also deleting the deadHMIState.selectedSitebranch inhmi-app.js);js/search.jsdrops its three-way app branch and emits canonical hashes; all threeindex.htmlload the new modules.docs/api-reference.mdis unaffected (no HTTP routes change). -
Tests:
routes.jsis DOM-free — unit-test the grammar, legacy table, and per-app resolution undermake 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.tsbare#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;
DcsTreeextraction, HMI and System/Data both consuming it;-
syncHashwrites 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.