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.
| Setting | Value | Why |
|---|---|---|
| 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-INF | The location Maven includes in the bundle. |
| build.emptyOutDir | true | Keeps hashed files from earlier builds from piling up and growing the bundle. |
| server.port | A different value per app | Avoids 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 build | Why |
|---|---|
| A servlet to serve static files | The platform serves the bundle's WEB-INF directly. |
| An Include-Resource instruction | Maven already includes src/main/resources in the bundle. |
| tailwind.config.js | Tailwind CSS 4 reads the theme block in the stylesheet, so no config file is needed. |
| postcss.config.js | Same as above. |
| A router library | The 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.