Creating a UI project

Let's add a screen to the sample app. It already has a connect profile and query commands, so putting a screen on top of it completes the backend extension and the UI as one app. The files you add are the same when you start a new app from scratch.

Directory layout

Adding a UI creates the files below. Leave the existing Java sources as they are.

logpresso-sample-app/
├── pom.xml                              (modified)
├── .gitignore                           (modified)
├── src/main/resources/
│   ├── sonar_app.json                   app manifest
│   ├── sonar_app_logo.png               64x64 RGBA
│   └── WEB-INF/                         build output, not version controlled
└── src/main/ui/
    ├── .env.example
    ├── index.html
    ├── package.json
    ├── tsconfig.json
    ├── vite.config.ts
    └── src/
        ├── main.tsx                     entry point, handles navigation
        ├── styles/global.css            design-system tokens
        ├── common/
        │   ├── theme.ts                 follows the web console theme
        │   └── i18n.ts                  localized strings
        └── subnet-groups/
            ├── SubnetGroupsPage.tsx     the screen
            ├── api.ts                   REST API calls
            └── types.ts                 response types

Create one directory per screen. An app with several screens keeps a directory like subnet-groups for each one, holding its component, its API calls, and its type definitions together.

Add the build output and per-developer settings to .gitignore.

src/main/ui/node/
src/main/ui/node_modules/
src/main/ui/tsconfig.tsbuildinfo
src/main/ui/.env
src/main/ui/.env.local
src/main/resources/WEB-INF/

.env.local holds the connection settings the dev server uses. It must not reach the repository, so exclude it and ship an .env.example with empty values instead.

Maven configuration

Add the frontend build and the app packaging to pom.xml. Maven pom.xml explains what each element means; this section covers only what changes because of the UI.

First define the app code and the Node.js versions in the properties.

<properties>
    <app.code>sample</app.code>
    <node.version>v20.18.0</node.version>
    <npm.version>10.9.0</npm.version>
</properties>

Add the packages the REST API plugin uses to the maven-bundle-plugin configuration.

<Import-Package>
    org.araqne.msgbus;version="1.12.0",
    org.araqne.msgbus.handler;version="1.12.0",
    org.araqne.msgbus.rest;version="1.12.0",
    ...
</Import-Package>
<Private-Package>
    com.logpresso.sonar.sample.impl,
    com.logpresso.sonar.sample.msgbus,
    com.logpresso.sonar.sample.query,
</Private-Package>

Project configuration files

src/main/ui/index.html holds only the element React mounts into and the entry point script.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Sample App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

This is src/main/ui/package.json. The build script runs the type check and the build together, so a type error fails the Maven build.

{
  "name": "logpresso-sample-app-ui",
  "private": true,
  "version": "1.1.2608.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-icons": "^5.4.0"
  },
  "devDependencies": {
    "@tailwindcss/vite": "^4.0.10",
    "@types/node": "^22.10.0",
    "@types/react": "^19.0.10",
    "@types/react-dom": "^19.0.4",
    "@vitejs/plugin-react-swc": "^3.8.0",
    "tailwindcss": "^4.0.10",
    "typescript": "~5.7.2",
    "vite": "^6.2.0"
  }
}

This is src/main/ui/tsconfig.json. It uses strict mode and only checks types without emitting files; Vite does the actual transformation.

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src"]
}

Build configuration

src/main/ui/vite.config.ts is the file to be most careful with in an XDR UI. Get the paths wrong and the build succeeds but the screen does not appear.

import { defineConfig, loadEnv } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react-swc';

const APP_CODE = 'sample';

export default defineConfig(({ mode }) => {
  const isProduction = mode === 'production';
  const env = loadEnv(mode, process.cwd(), '');

  return {
    plugins: [react(), tailwindcss()],
    base: isProduction ? `/app/${APP_CODE}/` : '/',
    build: {
      outDir: '../resources/WEB-INF',
      emptyOutDir: true,
      rollupOptions: {
        output: {
          entryFileNames: 'assets/[name]-[hash].js',
          chunkFileNames: 'assets/[name]-[hash].js',
          assetFileNames: 'assets/[name]-[hash][extname]',
        },
      },
    },
    server: {
      port: 6100,
      proxy: {
        [`/api/sonar/${APP_CODE}`]: {
          target: env.SONAR_URL || 'https://localhost',
          changeOrigin: true,
          secure: false,
          configure: proxy => {
            proxy.on('proxyReq', proxyReq => {
              if (env.SONAR_API_KEY)
                proxyReq.setHeader('Authorization', `Bearer ${env.SONAR_API_KEY}`);
            });
          },
        },
      },
    },
  };
});

These are the settings that matter.

SettingValueWhy
base (production)/app/{app_code}/The path the platform serves app resources from. A different value and the browser cannot find the scripts.
base (development)/The dev server serves without the app code path, so there must be no prefix.
build.outDir../resources/WEB-INFThe location Maven includes in the bundle.
build.emptyOutDirtrueKeeps hashed files from earlier builds from piling up and growing the bundle.
server.portA different value per appAvoids collisions when you develop several apps at once.
server.proxy/api/sonar/{app_code}Lets the dev server call a real Logpresso Sonar REST API.

Building and installing a UI explains why the proxy attaches an authorization header and how to write .env.local.

Theme integration

An app screen has to follow the web console theme. Do not add a separate theme switch inside the app. src/main/ui/src/common/theme.ts reads the console theme and applies it to the document element.

export type Theme = 'light' | 'dark';

type SonarGlobal = { theme?: string; skin?: string; locale?: string };

export function hostTheme(): Theme | null {
  try {
    const sonar = (window.parent as unknown as { SONAR?: SonarGlobal })?.SONAR
      ?? (window as unknown as { SONAR?: SonarGlobal })?.SONAR;
    const t = sonar?.theme ?? sonar?.skin;
    if (t === 'dark' || t === 'light')
      return t;
  } catch { /* cross-origin or absent */ }

  return null;
}

export function initialTheme(): Theme {
  const host = hostTheme();
  if (host)
    return host;

  try {
    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
      return 'dark';
  } catch { /* no matchMedia */ }

  return 'light';
}

export function getTheme(): Theme {
  return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
}

export function applyTheme(theme: Theme): void {
  document.documentElement.dataset.theme = theme;
}

export function watchHostTheme(): () => void {
  const sync = () => {
    const host = hostTheme();
    if (host && host !== getTheme())
      applyTheme(host);
  };

  const timer = window.setInterval(sync, 2000);
  window.addEventListener('focus', sync);
  document.addEventListener('visibilitychange', sync);

  return () => {
    window.clearInterval(timer);
    window.removeEventListener('focus', sync);
    document.removeEventListener('visibilitychange', sync);
  };
}

The theme resolves in this order: the web console theme, the operating system setting, then light. You cannot read the console theme when you run the app standalone on the dev server.

The two functions are called at different times. Call applyTheme(initialTheme()) before the screen is first painted. Otherwise the screen paints once in the light theme and then flips to dark, which shows as a flash. Keep watchHostTheme() alive for as long as the component lives. The platform does not notify the iframe when a user changes the theme in the console, so it polls, and it also re-checks whenever the screen becomes active again.

What you do not have to build

These are commonly added to an XDR UI without being needed.

Do not buildWhy
A servlet to serve static filesThe platform serves the bundle's WEB-INF directly.
An Include-Resource instructionMaven already includes src/main/resources in the bundle.
tailwind.config.jsTailwind CSS 4 reads the theme block in the stylesheet, so no config file is needed.
postcss.config.jsSame as above.
A router libraryThe web console owns the history, so you only parse the path string.

Agent prompt

Use this when you delegate adding a UI project to an existing app. Replace the app code and screen name with your own values.

Add an XDR UI to a Logpresso app.

App code: sample
Bundle name: com.logpresso.sonar.sample
First screen: subnet group list

References:
- https://docs.logpresso.com/en/app-sdk/create-ui-project (file layout)
- https://docs.logpresso.com/en/app-sdk/design-system (tokens and specifications)
- https://design.logpresso.com/design-system.manifest.json (design system entry point)

Comply with:
- vite base is /app/{app_code}/ in production and / in development
- build.outDir is ../resources/WEB-INF
- do not write a servlet to serve static files
- apply the theme before the first paint
- the screen handles the default, loading, empty, error, permission, and selected states

Afterwards check that mvn clean package passes and that the generated
WEB-INF/index.html points at /app/{app_code}/assets/.

The next section explains how to build and install an app that includes a UI.