Skip to content

feat: canonical app path serving#23086

Draft
netroms wants to merge 28 commits intomasterfrom
canonical_app_serving
Draft

feat: canonical app path serving#23086
netroms wants to merge 28 commits intomasterfrom
canonical_app_serving

Conversation

@netroms
Copy link
Contributor

@netroms netroms commented Feb 26, 2026

Summary

Adds a feature-flagged canonical app serving mode where all app resources are served from /apps/{appName}/ instead of the legacy /dhis-web-{appName}/. This eliminates the double resource loading that occurs when the global shell is enabled, where shared dependencies (fonts, CSS) are downloaded twice from different URL namespaces.

Feature flag: canonicalAppPaths system setting (default: false). Zero behavior change until explicitly enabled. Requires globalShellEnabled=true -- automatically treated as false when the global shell is disabled.

No frontend changes required. The global-shell-app already handles both naming schemes.

Builds on top of PR #23000 (static cache layer).

Problem

When the global shell is enabled and a user navigates to /apps/reports:

  1. GlobalShellFilter serves the global-shell's index.html with relative asset paths (./assets/main.js)
  2. These resolve to /apps/reports/assets/main.js -- served from the global-shell app
  3. The global shell creates an iframe loading /dhis-web-reports/index.html?redirect=false (the legacy URL from defaultAction)
  4. The Reports app's relative assets resolve to /dhis-web-reports/assets/font.woff2
  5. The same font file is now downloaded twice -- once from /apps/... and once from /dhis-web-reports/...

This happens because the browser treats them as different URLs despite serving identical content.

Root cause traced

The chain of causation starts in the backend:

  1. App.init() uses BUNDLED_APP_PREFIX = "dhis-web-" to build basePath and launchUrl
  2. WebModule.getModule() copies launchUrl into defaultAction
  3. GET /api/apps/menu returns defaultAction: "/dhis-web-reports/index.html"
  4. Global shell's PluginLoader creates an iframe at that legacy URL
  5. Two URL namespaces serve two bundles that share dependencies

The global-shell-app frontend code already anticipates the change:

// If core apps get a different naming scheme, this needs revisiting
return modules.find(
    (m) => m.name === appName || m.name === 'dhis-web-' + appName
)?.defaultAction

Solution

Feature flag

A canonicalAppPaths system setting gates all new behavior:

# Enable
curl -X POST -u admin:district "http://localhost:8080/api/systemSettings/canonicalAppPaths" \
  -H "Content-Type: text/plain" -d "true"

# Disable (back to legacy behavior)
curl -X POST -u admin:district "http://localhost:8080/api/systemSettings/canonicalAppPaths" \
  -H "Content-Type: text/plain" -d "false"

When disabled (default): zero behavior change -- everything works exactly as before.

What changes when enabled

1. App.init() uses canonical paths

All apps (bundled AND installed) use CANONICAL_APP_PREFIX = "apps/" instead of "dhis-web-" or "api/apps/". This changes basePath, baseUrl, launchUrl, and consequently defaultAction in /api/apps/menu.

Before: defaultAction: "/dhis-web-reports/index.html"
After: defaultAction: "/apps/reports/index.html"

2. GlobalShellFilter routes subresources to the actual app

Previously, ALL /apps/** subresource requests were forwarded to the global-shell app. Now the routing is app-aware:

Path Behavior
/apps/{appName} (exact, no subpath) Serves global-shell's index.html with rewritten asset paths
/apps/{appName}/index.html (no redirect=false) Redirects to /apps/{appName} (shell's router only understands the short form)
/apps/{appName}/index.html?redirect=false Serves actual app's HTML (for the iframe)
/apps/global-shell/{resource} Always forwards to global-shell's own resource (regardless of flag)
/apps/{knownApp}/{resource} + flag ON Forwards to actual {knownApp} app's resource
/apps/{unknown}/{resource} or flag OFF Falls back to global-shell resource (original behavior)

The "known app" check (appManager.getApp(appName) != null) prevents paths like /apps/assets/main.js from being misrouted -- assets is not an app, so it falls back to global-shell's assets/main.js.

3. Direct URL rewriting for global shell assets

When serving the global shell's index.html at /apps/{appName}, all relative asset paths are rewritten to absolute paths pointing at the global-shell app:

Before (raw HTML):  src="./assets/main-DH0lLmwl.js"
After (served):     src="/apps/global-shell/assets/main-DH0lLmwl.js"

This is done via string replacement on the Jsoup-processed HTML:

html = html.replace("href=\"./", "href=\"" + shellBasePath);
html = html.replace("src=\"./", "src=\"" + shellBasePath);

Uses request.getContextPath() so it works correctly with context path deployments (host.com/server_a).

Why direct URL rewriting instead of <base> tag: The initial approach injected <base href="{contextPath}/apps/global-shell/">. While curl confirmed the tag was present in the HTML, browsers did not respect it (assets still resolved against the document URL). Direct URL rewriting is foolproof and has no side effects on JavaScript relative URL resolution.

4. Canonical-aware service worker replacement

When canonical paths are ON, the global shell's standard @dhis2/pwa service worker is replaced with a lightweight canonical-aware service worker. Any request for service-worker.js under /apps/ is intercepted and our replacement is served from a classpath resource (canonical-service-worker.js).

The canonical SW:

  • Caches global-shell assets (/apps/global-shell/**) with cache-first strategy for instant reloads
  • Handles @dhis2/pwa message protocol (GET_CLIENTS_INFO, SKIP_WAITING, CLAIM_CLIENTS, GET_IMMEDIATE_DHIS2_CONNECTION_STATUS_UPDATE) so the shell's PWA code initializes correctly
  • Does NOT intercept navigation or non-shell fetches -- all other caching is handled by HTTP Cache-Control headers from StaticCacheControlService
  • Cleans up old Workbox caches on activation to avoid stale data from the previous SW

This is necessary because the standard @dhis2/pwa SW was designed for the legacy URL scheme -- it precaches the shell's index.html and serves it for ALL navigation requests under /apps/, including iframe URLs for actual apps. With canonical paths, this causes redirect loops, 404s, and React Router errors.

When canonical paths are OFF, the real service worker is served as before.

5. Legacy paths get 302 redirects (only for actual apps)

/dhis-web-{appName}/** requests are 302-redirected to /apps/{appName}/** instead of being internally forwarded, but only when {appName} is a known app. Non-app legacy paths (e.g. /dhis-web-commons/menu/getModules.action, /dhis-web-apps/apps-bundle.json) are not redirected and fall through to their original handlers. This:

  • Preserves backwards compatibility (old bookmarks, hardcoded URLs)
  • Avoids 404s for non-app paths like commons (shared static) and apps (bundle info)
  • Uses 302 (temporary) during the feature-flag phase so browsers do not cache redirects permanently
  • Both GlobalShellFilter and AppOverrideFilter do this

Result: single URL namespace

Before (flag off):                        After (flag on):
/apps/reports                             /apps/reports
  -> shell HTML (relative paths)             -> shell HTML with absolute /apps/global-shell/ paths
  -> /apps/reports/assets/shell.js           -> /apps/global-shell/assets/shell.js  (shell's own)
  -> iframe: /dhis-web-reports/index.html    -> iframe: /apps/reports/index.html?redirect=false
    -> /dhis-web-reports/assets/font.woff2     -> /apps/reports/assets/font.woff2
    -> /dhis-web-reports/assets/app.js         -> /apps/reports/assets/app.js

FONT LOADED TWICE (different URLs)        FONT LOADED ONCE (canonical URL)

Files changed

File Change
SystemSettings.java New getCanonicalAppPaths() setting (default: false); returns false when globalShellEnabled is false
AppManager.java New CANONICAL_APP_PREFIX = "apps/" constant
App.java init(contextPath, canonicalAppPaths) overload
DefaultAppManager.java Injects SystemSettingsProvider, passes flag to App.init()
DefaultAppManagerTest.java Updated for new constructor parameter
GlobalShellFilter.java Canonical routing with app-aware subresource dispatch, direct URL rewriting for shell HTML, 302 legacy redirects (only for known apps), canonical-aware SW replacement, /apps/{app}/index.html redirect
AppOverrideFilter.java 302 redirect when flag is on (instead of internal forward)
canonical-service-worker.js New classpath resource: lightweight SW that caches shell assets and handles @dhis2/pwa message protocol

Gotchas (fixed during implementation)

  1. 301 vs 302: Initially used 301 (Moved Permanently). Browsers cache 301 redirects aggressively. When the user disabled the feature flag, cached 301s caused legacy paths to still redirect to canonical, then 404. Fix: Use 302 (Found) during the feature-flag phase so redirects are not cached permanently.

  2. Redirecting non-app paths: The legacy pattern /dhis-web-{X}/** matched any X. Paths like /dhis-web-commons/menu/getModules.action and /dhis-web-apps/apps-bundle.json were redirected to /apps/commons/... and /apps/apps/..., which then 404'd ("App 'commons' not found"). Fix: Only redirect when appManager.getApp(appName) returns a non-null app. Non-app paths fall through to their original handlers.

  3. <base> tag not respected by browsers: Injecting <base href="/apps/global-shell/"> into the shell's HTML was confirmed present by curl, but browsers ignored it -- assets still resolved relative to the document URL. Fix: Replaced with direct URL rewriting (href="./ to href="/apps/global-shell/). This is simpler, more reliable, and avoids <base> tag side effects on JavaScript relative URL resolution.

  4. APP_SUBRESOURCE_PATTERN too aggressive: The pattern treating the first path segment after /apps/ as an app name broke paths like /apps/assets/main.js (where assets is a directory in global-shell, not an app). Fix: Check appManager.getApp(appName) != null before routing to an app. Unknown names fall back to the original behavior (full path treated as global-shell resource).

  5. RequestDispatcher.forward() hang: The initial approach used RequestDispatcher.forward() with an HttpServletResponseWrapper to capture the shell's HTML for rewriting. The forward blocked indefinitely (likely re-entering the filter chain). Fix: Load the resource directly via AppManager.getAppResource() and apply cache-busting + URL rewriting in the filter, bypassing the servlet dispatch entirely.

  6. canonicalAppPaths requires globalShellEnabled: With global shell OFF, redirectDisabledGlobalShell() redirects ALL /apps/** subresource paths to the root URL. With canonical paths ON, the app's launchUrl becomes /apps/{appName}/index.html, which triggers that redirect -- breaking the app. Fix: SystemSettings.getCanonicalAppPaths() now returns false when getGlobalShellEnabled() is false. The dependency is enforced at the settings level so all callers automatically get the right behavior.

  7. Service worker incompatible with canonical paths: The @dhis2/pwa Workbox SW precaches index.html and serves it for ALL navigation requests, causing redirect loops and 404s when canonical paths route different resources through /apps/. Fix: Replace the SW with a canonical-aware version that only caches /apps/global-shell/** assets (cache-first) and handles the @dhis2/pwa message protocol (GET_CLIENTS_INFO, SKIP_WAITING, CLAIM_CLIENTS, GET_IMMEDIATE_DHIS2_CONNECTION_STATUS_UPDATE). Key details:

    • Must call self.clients.claim() in activate -- without it, navigator.serviceWorker.controller is null and the shell's OfflineInterface.swMessage() throws, crashing the React tree on reload.
    • Must respond to GET_CLIENTS_INFO (not CLIENTS_INFO) -- the message types are asymmetric (client sends GET_..., SW responds with ...).
    • Must respond to GET_IMMEDIATE_DHIS2_CONNECTION_STATUS_UPDATE with a default isConnected: true to prevent errors from the connection status subscription.
    • Must NOT clear all caches synchronously in activate -- caches.keys() + Promise.all(delete) combined with clients.claim() caused the shell to hang. Only clean up known Workbox cache names (workbox-*, other-assets, app-shell).
  8. Shell router doesn't match /apps/{app}/index.html: The shell's React Router only matches /apps/{appName} (no index.html). If the browser URL changes to /apps/dashboard/index.html#/ (via the app's router or <base target="_top">), a reload serves the shell HTML but the router can't match the path. Fix: Redirect /apps/{appName}/index.html (without redirect=false) to /apps/{appName}. The iframe URL with ?redirect=false still serves the actual app HTML.

Deployment / rollout plan

Step Action Risk Rollback
1 Merge PR (flag off by default) None Revert PR
2 QA tests with flag manually enabled Low Set flag back to false
3 Default flag to true for 2.43-rc1 Medium Set flag to false
4 Remove flag in 2.44 Low N/A

No frontend changes or coordination needed. The global-shell-app already handles both naming conventions.

Important for QA: When toggling the flag between ON and OFF during testing, clear the browser's service worker and site data to avoid stale cached responses. In production, this is not an issue since the flag will be set once and stay.

Test plan

Flag OFF (default) -- verify zero behavior change

# Apps load normally
# /api/apps/menu returns legacy defaultAction URLs
curl -s -u admin:district "http://localhost:8080/api/apps/menu" | python3 -m json.tool | grep defaultAction | head -3
# Expected: "/dhis-web-dashboard/index.html"

Flag ON -- enable and verify

# Enable the flag
curl -X POST -u admin:district "http://localhost:8080/api/systemSettings/canonicalAppPaths" \
  -H "Content-Type: text/plain" -d "true"

# 1. /api/apps/menu returns canonical URLs
curl -s -u admin:district "http://localhost:8080/api/apps/menu" | python3 -m json.tool | grep defaultAction | head -3
# Expected: "/apps/dashboard/index.html" (NOT "/dhis-web-dashboard/...")

# 2. Legacy path 302 redirects to canonical (only for actual apps)
curl -sI -u admin:district "http://localhost:8080/dhis-web-dashboard/assets/main.js" | grep -i "HTTP\|location"
# Expected: HTTP/1.1 302, Location: .../apps/dashboard/assets/main.js

# 2b. Non-app legacy paths are NOT redirected (e.g. commons, apps-bundle)
curl -sI -u admin:district "http://localhost:8080/dhis-web-commons/menu/getModules.action" | grep -i "HTTP"
# Expected: HTTP/1.1 200 (not 302, not 404)

# 3. Shell HTML has rewritten absolute paths
curl -s -u admin:district "http://localhost:8080/apps/dashboard" | grep -oE 'src="[^"]*"' | head -3
# Expected: src="/apps/global-shell/assets/main-XXXX.js?v=..."

# 4. Service worker is canonical-aware
curl -sI -u admin:district "http://localhost:8080/apps/service-worker.js" | grep -i "content-type\|cache-control"
# Expected: Content-Type: application/javascript, Cache-Control: no-store

# 5. /apps/{app}/index.html redirects to /apps/{app}
curl -sI -u admin:district "http://localhost:8080/apps/dashboard/index.html" | grep -i "HTTP\|location"
# Expected: HTTP/1.1 302, Location: .../apps/dashboard

# 6. Open any app in browser DevTools Network tab
#    - Shell resources from /apps/global-shell/assets/... (ServiceWorker or memory cache on reload)
#    - Inner app resources from /apps/{appName}/assets/...
#    - NO resources from /dhis-web-*/
#    - No duplicate font/CSS downloads

# 7. Context path support (if applicable)
#    Navigate to http://host:8080/server_a/apps/reports
#    View source: src="/server_a/apps/global-shell/assets/main-XXXX.js"

# 8. Disable flag -- verify everything reverts
#    IMPORTANT: Clear service worker first (DevTools > Application > Service Workers > Unregister)
curl -X POST -u admin:district "http://localhost:8080/api/systemSettings/canonicalAppPaths" \
  -H "Content-Type: text/plain" -d "false"
#    All apps should work exactly as before

Regression checks

  • All bundled apps load and function (dashboard, reports, data entry, maintenance, maps, messaging)
  • App install / uninstall via POST/DELETE /api/apps works
  • Login page renders correctly
  • App reload (PUT /api/apps) works
  • Global shell OFF mode still works (/api/systemSettings/globalShellEnabled = false)
  • Normal page reload works (SW controls the page, no hang)
  • Force reload works
  • Navigation between apps via the shell works

Service worker testing note

When toggling canonicalAppPaths between ON and OFF during QA testing:

  1. Change the setting
  2. Open DevTools > Application > Service Workers
  3. Click "Unregister" on the service worker
  4. Clear site data (DevTools > Application > Storage > Clear site data)
  5. Hard reload the page

This is only needed during testing when flipping the flag. In production, the flag is set once and the SW naturally refreshes on the next app upgrade.

Test results

Suite Result
AppControllerTest (integration) 23/23 pass
DefaultAppManagerTest pass
StaticCacheControlServiceTest 20/20 pass
Spotless pass
Full compilation pass

Service Worker Analysis: Global Shell App

Context

The global-shell-app registers a Workbox-based service worker (pwa: { enabled: true } in d2.config.js). This analysis documents how the SW conflicts with canonical app paths, the replacement SW we built, and the technical details of the @dhis2/pwa message protocol.

What the original @dhis2/pwa service worker does

Workbox precaching

Precaches the shell's index.html during install. On navigation, serves the precached HTML instead of going to the network (after checking auth via a network fetch first).

Navigation route handler

Intercepts ALL navigation requests. If the network fetch succeeds with 200, returns the PRECACHED index.html (not the network response). This is the standard SPA pattern -- every URL gets the same HTML, the client-side router renders the right view.

Caching strategies

  • Precache: All build-time assets from the Workbox manifest
  • StaleWhileRevalidate: Image assets (jpg, gif, png, etc.)
  • NetworkFirst: Everything else that matches app shell criteria
  • NetworkAndTryCache: Default fallback

Message handlers

  • GET_CLIENTS_INFO -> responds with CLIENTS_INFO + { clientsCount }
  • SKIP_WAITING -> calls self.skipWaiting()
  • CLAIM_CLIENTS -> calls self.clients.claim() + reload clients
  • GET_IMMEDIATE_DHIS2_CONNECTION_STATUS_UPDATE -> broadcasts connection status
  • START_RECORDING / COMPLETE_RECORDING -> offline recording mode

PWA features

  • Online/offline status badge in the header bar
  • "Update available" prompt when new SW version detected
  • Offline recording mode for sections

How the original SW conflicts with canonical paths

The navigation route is the root cause

The original SW's navigation handler serves the PRECACHED index.html for every navigation under /apps/. With canonical paths:

  1. Navigate to /apps/dashboard -> SW intercepts, serves precached shell HTML
  2. Shell creates iframe to /apps/dashboard/index.html?redirect=false
  3. SW intercepts this navigation too (it matches the navigation criteria)
  4. SW serves the precached SHELL index.html instead of the DASHBOARD's HTML
  5. The iframe renders the shell (not the app), loads shell JS, shell tries to render -> broken

Stale cache after flag toggle

The Workbox precache contains the shell's index.html without URL rewriting (cached before canonical paths was enabled). Even with the flag toggled, the SW serves the old cached version.

redirect=false not understood by SW

The ?redirect=false query parameter is meaningful to GlobalShellFilter (serve actual app, not shell). But the SW's navigation handler doesn't check for it -- it serves precached HTML regardless of query parameters.

The canonical-aware replacement SW

Located at dhis-web-api/src/main/resources/canonical-service-worker.js. Served by GlobalShellFilter when canonicalAppPaths is ON.

What it does

  1. Caches global-shell assets (/apps/global-shell/**) with cache-first strategy. Hashed filenames are immutable, so the cache is always correct. After first load, shell assets are served from SW cache on subsequent navigations.

  2. Handles the @dhis2/pwa message protocol so the shell's React code initializes correctly:

    • GET_CLIENTS_INFO -> responds with CLIENTS_INFO + client count
    • SKIP_WAITING -> calls self.skipWaiting()
    • CLAIM_CLIENTS -> calls self.clients.claim()
    • GET_IMMEDIATE_DHIS2_CONNECTION_STATUS_UPDATE -> responds with isConnected: true
  3. Does NOT intercept navigation or non-shell fetches. All other caching is handled by HTTP Cache-Control headers from StaticCacheControlService.

  4. Cleans up old Workbox caches on activation (workbox-*, other-assets, app-shell).

What it does NOT do

  • No Workbox precaching
  • No navigation interception (the SPA fallback pattern)
  • No offline recording mode
  • No connection status tracking (responds with default isConnected: true)
  • No StaleWhileRevalidate or NetworkFirst strategies

Critical implementation details

These were discovered through iterative debugging:

  1. Must call self.clients.claim() in activate: Without this, navigator.serviceWorker.controller is null on the page. The shell's OfflineInterface.swMessage() helper checks navigator.serviceWorker.controller and THROWS if it's null. This crashes the React tree on page reload (first load works because the SW isn't controlling yet).

  2. Must listen for GET_CLIENTS_INFO (not CLIENTS_INFO): The message types are asymmetric. The client sends GET_CLIENTS_INFO, the SW responds with CLIENTS_INFO. Getting this wrong causes the shell's PWA code to time out, and the @dhis2/app-runtime Provider may block further initialization.

  3. Must respond to GET_IMMEDIATE_DHIS2_CONNECTION_STATUS_UPDATE: The shell subscribes to connection status updates early in initialization. The swMessage() helper sends this message via navigator.serviceWorker.controller.postMessage(). If the SW doesn't handle it, the message is silently lost (no error), but responding with { isConnected: true } prevents potential edge cases.

  4. Must NOT clear all caches synchronously with clients.claim(): Using caches.keys().then(keys => Promise.all(keys.map(k => caches.delete(k)))).then(() => self.clients.claim()) in the activate handler caused the shell to hang indefinitely. The fix: only delete known Workbox cache names (workbox-*, other-assets, app-shell), or skip cache clearing entirely.

  5. SW scope matters: The shell registers the SW from ./service-worker.js relative to the document URL. When the document is /apps/dashboard, the SW URL is /apps/service-worker.js (parent directory). The scope is /apps/. This means the SW controls ALL requests under /apps/, including app subresources. The fetch handler must be careful not to interfere.

@dhis2/pwa message protocol reference

Source: @dhis2/pwa/src/lib/constants.js

Constant Value Direction Purpose
getClientsInfo GET_CLIENTS_INFO Client -> SW Request client count
clientsInfo CLIENTS_INFO SW -> Client Response with { clientsCount }
skipWaiting SKIP_WAITING Client -> SW Activate waiting SW
claimClients CLAIM_CLIENTS Client -> SW Take control of clients
dhis2ConnectionStatusUpdate DHIS2_CONNECTION_STATUS_UPDATE SW -> Client Connection status change
getImmediateDhis2ConnectionStatusUpdate GET_IMMEDIATE_DHIS2_CONNECTION_STATUS_UPDATE Client -> SW Request immediate status
startRecording START_RECORDING Client -> SW Start offline recording
recordingStarted RECORDING_STARTED SW -> Client Recording started ack
recordingError RECORDING_ERROR SW -> Client Recording error
confirmRecordingCompletion CONFIRM_RECORDING_COMPLETION SW -> Client Confirm before completing
completeRecording COMPLETE_RECORDING Client -> SW Complete recording
recordingCompleted RECORDING_COMPLETED SW -> Client Recording done

Registration flow on localhost

The @dhis2/pwa registration code (registration.js) has a special localhost path:

  1. window.addEventListener('load', ...) -- waits for page load
  2. checkValidSW(swUrl) -- fetches service-worker.js via XHR
  3. If 404 or wrong content-type: navigator.serviceWorker.ready.then(reg => reg.unregister().then(() => reload())) -- WARNING: navigator.serviceWorker.ready never resolves if no SW is registered, causing the unregister+reload to silently hang (no error, no action)
  4. If valid JS: registerValidSW(swUrl) -> navigator.serviceWorker.register(swUrl)

This is why returning 404 for the SW works on fresh sessions (no pre-existing SW) but can cause issues when an old SW exists (the unregister code hangs on ready).

Recommendation

Current approach (2.43)

Serve the canonical-aware replacement SW when canonicalAppPaths is ON. This preserves the shell's PWA initialization, caches shell assets for fast reloads, and avoids all conflicts with canonical path routing.

Future (global-shell-app / @dhis2/pwa update)

When the global-shell-app is updated for canonical paths, the @dhis2/pwa package could adopt the canonical-aware SW approach we've already implemented and validated in canonical-service-worker.js. The key changes are documented in this analysis (message protocol, clients.claim() requirement, cache-first for shell assets, no navigation interception). This would allow the backend to stop intercepting service-worker.js and let the shell serve its own canonical-aware SW natively.

Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
…e_layer

# Conflicts:
#	dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
@netroms netroms changed the title Canonical app serving feat: canonical app path serving Feb 26, 2026
@netroms netroms added the deploy Deploy DHIS2 instance with IM. label Feb 26, 2026
@github-actions
Copy link

Instance deployed to https://dev.im.dhis2.org/pr-23086

Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
Signed-off-by: Morten Svanaes <msvanaes@dhis2.org>
@sonarqubecloud
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deploy Deploy DHIS2 instance with IM.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant