├── docs ├── latest.html ├── Gemfile ├── assets │ ├── images │ │ ├── screenshot1.png │ │ └── screenshot2.png │ ├── js │ │ └── main.js │ └── css │ │ └── style.css ├── preview.sh ├── _config.yml ├── _layouts │ └── default.html ├── latest.md └── index.md ├── frontend ├── .babelrc ├── src │ ├── index.js │ ├── components │ │ ├── LoadingSpinner.js │ │ ├── ErrorBoundary.js │ │ ├── HelpDialog.js │ │ ├── Header.js │ │ ├── JarItem.js │ │ ├── ApplicationsList.js │ │ ├── StatisticsCards.js │ │ └── ApplicationCard.js │ ├── utils │ │ └── helpers.js │ ├── styles │ │ └── globals.css │ └── hooks │ │ └── useDashboardData.js ├── public │ └── template.html ├── package-new.json ├── webpack.config.js ├── package.json └── README.md ├── .markdownlint.json ├── .vscode ├── settings.json └── tasks.json ├── .sdkmanrc ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── .github ├── scripts │ ├── build-java.sh │ ├── build-frontend.sh │ ├── get-version.sh │ ├── generate-changelog.sh │ └── create-release-package.sh ├── copilot-instructions.md └── workflows │ └── release.yml ├── sample-spring-app ├── src │ └── main │ │ └── java │ │ └── com │ │ └── example │ │ └── demo │ │ ├── package-info.java │ │ └── DemoApplication.java └── pom.xml ├── .gitignore ├── .markdown-link-check.json ├── server ├── src │ ├── main │ │ ├── java │ │ │ └── io │ │ │ │ └── github │ │ │ │ └── brunoborges │ │ │ │ └── jlib │ │ │ │ └── server │ │ │ │ ├── package-info.java │ │ │ │ ├── service │ │ │ │ ├── package-info.java │ │ │ │ ├── ApplicationService.java │ │ │ │ └── JarService.java │ │ │ │ ├── handler │ │ │ │ ├── package-info.java │ │ │ │ ├── HealthHandler.java │ │ │ │ ├── DashboardHandler.java │ │ │ │ ├── JarsHandler.java │ │ │ │ └── ReportHandler.java │ │ │ │ └── JLibServer.java │ │ └── resources │ │ │ └── logging.properties │ └── test │ │ └── java │ │ └── io │ │ └── github │ │ └── brunoborges │ │ └── jlib │ │ └── server │ │ └── ApplicationServiceTest.java ├── server.http └── pom.xml ├── docker ├── Dockerfile.frontend ├── Dockerfile.samplespring ├── Dockerfile.backend ├── docker-compose.yml ├── start-docker.sh └── README.md ├── .devcontainer ├── postCreate.sh └── devcontainer.json ├── .dockerignore ├── agent ├── src │ └── main │ │ └── java │ │ └── io │ │ └── github │ │ └── brunoborges │ │ └── jlib │ │ └── agent │ │ ├── package-info.java │ │ ├── JarInventory.java │ │ ├── jvm │ │ ├── IdentifyGC.java │ │ └── PrintFlagsFinal.java │ │ └── JarInventoryReport.java └── pom.xml ├── common ├── src │ ├── main │ │ └── java │ │ │ └── io │ │ │ └── github │ │ │ └── brunoborges │ │ │ └── jlib │ │ │ ├── common │ │ │ ├── package-info.java │ │ │ ├── JavaApplication.java │ │ │ └── ApplicationIdUtil.java │ │ │ └── json │ │ │ └── JsonResponseBuilder.java │ └── test │ │ └── java │ │ └── io │ │ └── github │ │ └── brunoborges │ │ └── jlib │ │ └── common │ │ └── JavaApplicationTest.java └── pom.xml ├── LICENSE ├── DOCKER.md ├── benchmark.sh └── demo-jlib-inspector.ps1 /docs/latest.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "github-pages", group: :jekyll_plugins 3 | gem "webrick", "~> 1.8" 4 | -------------------------------------------------------------------------------- /docs/assets/images/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunoborges/jlib-inspector/HEAD/docs/assets/images/screenshot1.png -------------------------------------------------------------------------------- /docs/assets/images/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunoborges/jlib-inspector/HEAD/docs/assets/images/screenshot2.png -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD013": { "line_length": 120 }, 4 | "MD033": false, 5 | "MD041": false 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "java.configuration.updateBuildConfiguration": "automatic", 3 | "java.compile.nullAnalysis.mode": "automatic" 4 | } -------------------------------------------------------------------------------- /.sdkmanrc: -------------------------------------------------------------------------------- 1 | # Enable auto-env through the sdkman_auto_env config 2 | # Add key=value pairs of SDKs to use below 3 | java=21.0.8-tem 4 | maven=4.0.0-rc-4 5 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionType=bin 2 | distributionUrl=https://dlcdn.apache.org/maven/maven-4/4.0.0-rc-4/binaries/apache-maven-4.0.0-rc-4-bin.zip 3 | -------------------------------------------------------------------------------- /.github/scripts/build-java.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | echo "Building Java components (clean verify)..." 5 | ./mvnw -q clean verify -B 6 | echo "Java build completed" 7 | -------------------------------------------------------------------------------- /docs/preview.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | docker run --rm -p 4000:4000 -v "$PWD/docs":/srv/jekyll -w /srv/jekyll jekyll/jekyll:4 \ 3 | bash -lc "bundle install && jekyll serve --livereload --host localhost" 4 | -------------------------------------------------------------------------------- /sample-spring-app/src/main/java/com/example/demo/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Minimal Spring Boot application used for local testing and demonstrations 3 | * of the jlib-inspector agent and server. 4 | */ 5 | package com.example.demo; 6 | -------------------------------------------------------------------------------- /.github/scripts/build-frontend.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | pushd frontend >/dev/null 4 | echo "Installing frontend dependencies (npm ci)..." 5 | npm ci 6 | echo "Building frontend (npm run build)..." 7 | npm run build 8 | popd >/dev/null 9 | echo "Frontend build completed" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Maven target folders 2 | target/** 3 | common/target/** 4 | agent/target/** 5 | server/target/** 6 | sample-*/target/** 7 | 8 | # Node folders 9 | frontend/node_modules/** 10 | frontend/dist/** 11 | 12 | # IntelliJ IDE files 13 | .idea/ 14 | docs/Gemfile.lock 15 | docs/_site/** 16 | -------------------------------------------------------------------------------- /.markdown-link-check.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": [ 3 | { 4 | "pattern": "^http://localhost" 5 | }, 6 | { 7 | "pattern": "^https://localhost" 8 | } 9 | ], 10 | "timeout": "10s", 11 | "retryOn429": true, 12 | "retryCount": 3, 13 | "fallbackHttpStatus": 200 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | // Create root and render the app 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render(); 8 | 9 | // Hot Module Replacement for development 10 | if (module.hot) { 11 | module.hot.accept(); 12 | } 13 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Lightweight HTTP backend that collects and serves JAR usage information from agents. 3 | * 4 | *

Key entrypoint: {@link io.github.brunoborges.jlib.server.JLibServer} — wires HTTP contexts 5 | * and starts the server. See subpackages for request handlers and services. 6 | */ 7 | package io.github.brunoborges.jlib.server; 8 | -------------------------------------------------------------------------------- /server/src/main/resources/logging.properties: -------------------------------------------------------------------------------- 1 | handlers= java.util.logging.ConsoleHandler 2 | .level=FINE 3 | io.github.brunoborges.jlib.inspector.ClassLoaderTrackerTransformer.level=FINE 4 | java.util.logging.ConsoleHandler.level=FINE 5 | java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter 6 | java.util.logging.SimpleFormatter.format=%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL %4$-5s [%2$s] %3$s - %5$s%6$s%n 7 | -------------------------------------------------------------------------------- /docker/Dockerfile.frontend: -------------------------------------------------------------------------------- 1 | # Use Node.js 18 as base image 2 | FROM node:18-alpine 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package files 8 | COPY ./frontend/package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm install 12 | 13 | # Copy frontend source code 14 | COPY ./frontend/ ./ 15 | 16 | # Build the frontend 17 | RUN npm run build:only 18 | 19 | # Expose port 3000 20 | EXPOSE 3000 21 | 22 | # Start the application 23 | CMD ["npm", "start"] 24 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/service/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Backend services supporting application and JAR aggregation logic. 3 | * 4 | *

8 | */ 9 | package io.github.brunoborges.jlib.server.service; 10 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/handler/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * HTTP request handlers for the backend API. 3 | * 4 | * 9 | */ 10 | package io.github.brunoborges.jlib.server.handler; 11 | -------------------------------------------------------------------------------- /.devcontainer/postCreate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | echo "[postCreate] Setting up workspace..." 4 | 5 | if [ -x "./mvnw" ]; then 6 | MVN=./mvnw 7 | else 8 | MVN=mvn 9 | fi 10 | 11 | echo "[postCreate] Building Java modules (skip tests)" 12 | $MVN -q -DskipTests package 13 | 14 | echo "[postCreate] Installing frontend dependencies" 15 | if [ -f frontend/package.json ]; then 16 | pushd frontend >/dev/null 17 | npm ci || npm install 18 | popd >/dev/null 19 | fi 20 | 21 | echo "[postCreate] Done." 22 | -------------------------------------------------------------------------------- /.github/scripts/get-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | if [[ "${GITHUB_EVENT_NAME:-}" == "workflow_dispatch" ]]; then 5 | if [[ -z "${INPUT_VERSION:-}" ]]; then 6 | echo "INPUT_VERSION not provided for workflow_dispatch" >&2 7 | exit 1 8 | fi 9 | VERSION="$INPUT_VERSION" 10 | else 11 | if [[ "${GITHUB_REF:-}" =~ refs/tags/ ]]; then 12 | VERSION="${GITHUB_REF#refs/tags/}" 13 | else 14 | echo "GITHUB_REF does not point to a tag; cannot derive version" >&2 15 | exit 1 16 | fi 17 | fi 18 | 19 | echo "Resolved version: $VERSION" 20 | echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" 21 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | 5 | # Documentation 6 | README.md 7 | *.md 8 | screenshot.png 9 | 10 | # IDE files 11 | .vscode 12 | .idea 13 | *.iml 14 | 15 | # OS files 16 | .DS_Store 17 | Thumbs.db 18 | 19 | # Log files 20 | *.log 21 | 22 | # Maven/Gradle build artifacts 23 | target/ 24 | build/ 25 | # Note: keeping .mvn/, mvnw, mvnw.cmd for Docker builds 26 | 27 | # Node.js 28 | frontend/node_modules/ 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | 33 | # Frontend build (we'll build inside container) 34 | frontend/dist/ 35 | 36 | # Docker files (don't copy into containers) 37 | Dockerfile* 38 | docker-compose*.yml 39 | .dockerignore 40 | -------------------------------------------------------------------------------- /frontend/public/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | JLib Inspector Dashboard 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/components/LoadingSpinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const LoadingSpinner = () => { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 | Loading dashboard... 12 |
13 |
14 | ); 15 | }; 16 | 17 | export default LoadingSpinner; 18 | -------------------------------------------------------------------------------- /frontend/package-new.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jlib-inspector-dashboard", 3 | "version": "2.0.0", 4 | "description": "Simplified JLib Inspector Dashboard - Single Node.js Application", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js", 8 | "dev": "nodemon app.js", 9 | "test": "echo \"No tests specified\" && exit 0" 10 | }, 11 | "keywords": ["java", "jar", "monitoring", "dashboard", "nodejs"], 12 | "author": "JLib Inspector Team", 13 | "license": "MIT", 14 | "dependencies": { 15 | "express": "^4.18.2", 16 | "cors": "^2.8.5", 17 | "axios": "^1.6.0", 18 | "ws": "^8.14.2", 19 | "node-cron": "^3.0.3" 20 | }, 21 | "devDependencies": { 22 | "nodemon": "^3.0.1" 23 | }, 24 | "engines": { 25 | "node": ">=14.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /agent/src/main/java/io/github/brunoborges/jlib/agent/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Java instrumentation agent that inspects JAR usage at runtime. 3 | * 4 | *

Main components: 5 | *

12 | */ 13 | package io.github.brunoborges.jlib.agent; 14 | -------------------------------------------------------------------------------- /docker/Dockerfile.samplespring: -------------------------------------------------------------------------------- 1 | # Use OpenJDK 21 as base image 2 | FROM openjdk:21-jdk-slim 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | COPY ./mvnw ./mvnw 8 | COPY ./.mvn ./.mvn 9 | RUN chmod +x ./mvnw 10 | 11 | COPY ./pom.xml ./pom.xml 12 | COPY ./common/pom.xml ./common/pom.xml 13 | COPY ./agent/pom.xml ./agent/pom.xml 14 | COPY ./sample-spring-app/pom.xml ./sample-spring-app/pom.xml 15 | RUN ./mvnw -P sample-spring dependency:go-offline 16 | 17 | COPY ./common/src ./common/src 18 | COPY ./agent/src ./agent/src 19 | COPY ./sample-spring-app/src ./sample-spring-app/src 20 | 21 | RUN ./mvnw -P sample-spring verify 22 | 23 | # Run the JLib server shaded jar 24 | CMD ["java", "-javaagent:agent/target/jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar=server:8080", "-jar", "sample-spring-app/target/sample-spring-app-1.0-SNAPSHOT.jar"] -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: JLib Inspector 2 | description: Runtime inventory of the libraries actually loaded by your JVM. 3 | url: https://brunoborges.github.io 4 | baseurl: /jlib-inspector 5 | repository: brunoborges/jlib-inspector 6 | theme: minima 7 | markdown: kramdown 8 | plugins: 9 | - jekyll-seo-tag 10 | - jekyll-sitemap 11 | - jekyll-feed 12 | 13 | exclude: 14 | - Gemfile* 15 | - vendor 16 | 17 | nav: 18 | - text: Home 19 | url: / 20 | - text: Why 21 | url: /#why 22 | - text: Architecture 23 | url: /#architecture 24 | - text: Quick Start 25 | url: /#quick-start 26 | - text: GitHub 27 | url: https://github.com/brunoborges/jlib-inspector 28 | 29 | # Social / SEO extras (optional placeholders) 30 | twitter_username: brunoborges 31 | github_username: brunoborges 32 | logo: /assets/img/logo.svg 33 | 34 | -------------------------------------------------------------------------------- /common/src/main/java/io/github/brunoborges/jlib/common/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared data model and utilities used by both the agent and the server. 3 | * 4 | *

Core types include: 5 | *

10 | * 11 | *

Thread-safety considerations: 12 | *

16 | */ 17 | package io.github.brunoborges.jlib.common; 18 | -------------------------------------------------------------------------------- /docker/Dockerfile.backend: -------------------------------------------------------------------------------- 1 | # Use OpenJDK 21 as base image 2 | FROM openjdk:21-jdk-slim 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy JAR 8 | # COPY ./server/target/jlib-inspector-server-1.0-SNAPSHOT-shaded.jar jlib-inspector-server-1.0-SNAPSHOT-shaded.jar 9 | 10 | COPY ./mvnw ./mvnw 11 | COPY ./.mvn ./.mvn 12 | RUN chmod +x ./mvnw 13 | 14 | COPY ./pom.xml ./pom.xml 15 | COPY ./common/pom.xml ./common/pom.xml 16 | COPY ./server/pom.xml ./server/pom.xml 17 | 18 | RUN ./mvnw -P server dependency:go-offline 19 | 20 | COPY ./common/src ./common/src 21 | COPY ./server/src ./server/src 22 | 23 | RUN ./mvnw -P server verify 24 | RUN cp ./server/target/jlib-inspector-server-1.0-SNAPSHOT-shaded.jar ./jlib-inspector-server-1.0-SNAPSHOT-shaded.jar 25 | 26 | # Expose port 8080 27 | EXPOSE 8080 28 | 29 | # Run the JLib server shaded jar 30 | CMD ["java", "-jar", "jlib-inspector-server-1.0-SNAPSHOT-shaded.jar", "8080"] 31 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | # JLib Server (Backend) 5 | server: 6 | build: 7 | context: ../ 8 | dockerfile: docker/Dockerfile.backend 9 | ports: 10 | - "8080:8080" 11 | environment: 12 | - JDK_JAVA_OPTIONS=-Xms512m -Xmx1g 13 | 14 | # Frontend (Dashboard) 15 | frontend: 16 | build: 17 | context: ../ 18 | dockerfile: docker/Dockerfile.frontend 19 | ports: 20 | - "3000:3000" 21 | environment: 22 | - PORT=3000 23 | - JLIB_SERVER_URL=http://server:8080 24 | depends_on: 25 | - server 26 | restart: on-failure 27 | 28 | sample-spring-app: 29 | build: 30 | context: ../ 31 | dockerfile: docker/Dockerfile.samplespring 32 | environment: 33 | - JDK_JAVA_OPTIONS=-Xms512m -Xmx1g 34 | - JLIB_SERVER_URL=http://server:8080 35 | depends_on: 36 | - server 37 | 38 | networks: 39 | default: 40 | driver: bridge 41 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jlib-inspector", 3 | "image": "mcr.microsoft.com/devcontainers/base:jammy", 4 | "features": { 5 | "ghcr.io/devcontainers/features/java:1": { 6 | "version": "21", 7 | "jdkDistro": "temurin" 8 | }, 9 | "ghcr.io/devcontainers/features/maven:1": { 10 | "version": "3.9" 11 | }, 12 | "ghcr.io/devcontainers/features/node:1": { 13 | "version": "18" 14 | } 15 | }, 16 | "postCreateCommand": "bash .devcontainer/postCreate.sh", 17 | "forwardPorts": [8080, 3000, 3001], 18 | "portsAttributes": { 19 | "8080": { "label": "JLib Server" }, 20 | "3000": { "label": "Dashboard (HTTP)" }, 21 | "3001": { "label": "Dashboard (WS)" } 22 | }, 23 | "customizations": { 24 | "vscode": { 25 | "extensions": [ 26 | "vscjava.vscode-java-pack", 27 | "vscjava.vscode-maven", 28 | "dbaeumer.vscode-eslint", 29 | "esbenp.prettier-vscode", 30 | "mhutchie.git-graph" 31 | ] 32 | } 33 | }, 34 | "remoteUser": "vscode" 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Build (Maven)", 6 | "type": "shell", 7 | "command": "${workspaceFolder}/mvnw", 8 | "args": ["-q", "clean", "verify"], 9 | "problemMatcher": [] 10 | }, 11 | { 12 | "label": "Start Server", 13 | "type": "shell", 14 | "command": "java", 15 | "args": ["-jar", "server/target/jlib-inspector-server-1.0-SNAPSHOT.jar", "8080"], 16 | "problemMatcher": [] 17 | }, 18 | { 19 | "label": "Start Frontend", 20 | "type": "shell", 21 | "command": "bash", 22 | "args": ["-lc", "cd frontend && npm start"], 23 | "isBackground": true, 24 | "problemMatcher": [] 25 | }, 26 | { 27 | "label": "Run Sample App (with agent)", 28 | "type": "shell", 29 | "command": "bash", 30 | "args": [ 31 | "-lc", 32 | "java -javaagent:agent/target/jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar=server:8080 -jar sample-spring-app/target/sample-spring-app-1.0-SNAPSHOT.jar" 33 | ], 34 | "problemMatcher": [] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /sample-spring-app/src/main/java/com/example/demo/DemoApplication.java: -------------------------------------------------------------------------------- 1 | package com.example.demo; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.boot.CommandLineRunner; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | 9 | /** 10 | * Sample Spring Boot application used to demonstrate and test the jlib-inspector 11 | * agent attached to a running Java process. This app doesn't expose HTTP 12 | * endpoints; it simply starts up and logs the command-line arguments so that 13 | * the agent can observe class loading and JAR usage. 14 | */ 15 | @SpringBootApplication 16 | public class DemoApplication implements CommandLineRunner { 17 | 18 | private static Logger LOG = LoggerFactory.getLogger(DemoApplication.class); 19 | 20 | public static void main(String[] args) { 21 | LOG.info("STARTING THE APPLICATION"); 22 | SpringApplication.run(DemoApplication.class, args); 23 | LOG.info("APPLICATION FINISHED"); 24 | } 25 | 26 | @Override 27 | public void run(String... args) { 28 | LOG.info("EXECUTING : command line runner"); 29 | 30 | for (int i = 0; i < args.length; ++i) { 31 | LOG.info("args[{}]: {}", i, args[i]); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './src/index.js', 6 | output: { 7 | path: path.resolve(__dirname, 'dist'), 8 | filename: 'bundle.js', 9 | clean: true, 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.(js|jsx)$/, 15 | exclude: /node_modules/, 16 | use: { 17 | loader: 'babel-loader', 18 | options: { 19 | presets: ['@babel/preset-env', '@babel/preset-react'] 20 | } 21 | } 22 | }, 23 | { 24 | test: /\.css$/i, 25 | use: ['style-loader', 'css-loader'], 26 | } 27 | ] 28 | }, 29 | resolve: { 30 | extensions: ['.js', '.jsx'] 31 | }, 32 | plugins: [ 33 | new HtmlWebpackPlugin({ 34 | template: './public/template.html', 35 | filename: 'index.html' 36 | }) 37 | ], 38 | devServer: { 39 | static: { 40 | directory: path.join(__dirname, 'dist'), 41 | }, 42 | compress: true, 43 | port: 3000, 44 | hot: true, 45 | historyApiFallback: true, 46 | proxy: [ 47 | { 48 | context: ['/api'], 49 | target: 'http://localhost:3001', 50 | changeOrigin: true, 51 | } 52 | ] 53 | }, 54 | mode: 'development' 55 | }; 56 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jlib-inspector-dashboard", 3 | "version": "2.0.0", 4 | "description": "Simplified JLib Inspector Dashboard - Single Node.js Application", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "npm run build && node app.js", 8 | "build": "webpack --mode production", 9 | "dev": "npm run build && node app.js", 10 | "build:only": "webpack --mode production", 11 | "test": "echo \"No tests specified\" && exit 0" 12 | }, 13 | "keywords": [ 14 | "java", 15 | "jar", 16 | "monitoring", 17 | "dashboard", 18 | "nodejs" 19 | ], 20 | "author": "JLib Inspector Team", 21 | "license": "MIT", 22 | "dependencies": { 23 | "@babel/core": "^7.28.4", 24 | "@babel/preset-env": "^7.28.3", 25 | "@babel/preset-react": "^7.27.1", 26 | "axios": "^1.11.0", 27 | "babel-loader": "^10.0.0", 28 | "cors": "^2.8.5", 29 | "css-loader": "^7.1.2", 30 | "express": "^5.1.0", 31 | "html-webpack-plugin": "^5.6.4", 32 | "node-cron": "^4.2.1", 33 | "react": "^19.1.1", 34 | "react-dom": "^19.1.1", 35 | "style-loader": "^4.0.0", 36 | "webpack": "^5.101.3", 37 | "webpack-cli": "^6.0.1", 38 | "webpack-dev-server": "^5.2.2", 39 | "ws": "^8.18.3" 40 | }, 41 | "devDependencies": { 42 | "nodemon": "^3.1.10" 43 | }, 44 | "engines": { 45 | "node": ">=14.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/scripts/generate-changelog.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | VERSION="${VERSION:-}" 5 | if [[ -z "$VERSION" ]]; then 6 | echo "VERSION env var required" >&2 7 | exit 1 8 | fi 9 | 10 | if git rev-parse --verify HEAD^ >/dev/null 2>&1; then 11 | LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") 12 | else 13 | LAST_TAG="" 14 | fi 15 | 16 | if [[ -n "$LAST_TAG" ]]; then 17 | CHANGELOG_BODY=$(git log --pretty=format:"- %s (%an)" "$LAST_TAG"..HEAD) 18 | else 19 | CHANGELOG_BODY=$(git log --pretty=format:"- %s (%an)" --max-count=20) 20 | fi 21 | 22 | { 23 | echo "## What's Changed" 24 | echo 25 | echo "$CHANGELOG_BODY" 26 | echo 27 | echo "## Installation" 28 | echo 29 | echo "1. Download an archive for your platform" 30 | echo "2. Extract it" 31 | echo "3. Run install.sh (Linux/macOS) or install.bat (Windows)" 32 | echo "4. Follow README instructions" 33 | echo 34 | echo "## Quick Start" 35 | echo 36 | echo '```bash' 37 | echo '# Run the demo' 38 | echo './demo-jlib-inspector.ps1' 39 | echo 40 | echo '# Use with your application' 41 | echo 'java -javaagent:jlib-inspector-agent-*.jar -jar your-app.jar' 42 | echo '```' 43 | } > /tmp/changelog.txt 44 | 45 | echo "CHANGELOG<> "$GITHUB_OUTPUT" 46 | cat /tmp/changelog.txt >> "$GITHUB_OUTPUT" 47 | echo "EOF" >> "$GITHUB_OUTPUT" 48 | 49 | echo "Changelog generated for $VERSION" 50 | -------------------------------------------------------------------------------- /docker/start-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Starting JLib Inspector with Docker Compose..." 4 | echo "This will build and start both the backend server and frontend dashboard." 5 | echo "" 6 | 7 | # Check if Docker and Docker Compose are available 8 | if ! command -v docker &> /dev/null; then 9 | echo "Error: Docker is not installed or not in PATH" 10 | exit 1 11 | fi 12 | 13 | if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then 14 | echo "Error: Docker Compose is not installed or not in PATH" 15 | exit 1 16 | fi 17 | 18 | # Move to the directory containing this script (and docker-compose.yml) 19 | SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 20 | cd "$SCRIPT_DIR" 21 | 22 | # Build and start services 23 | echo "Building and starting services..." 24 | 25 | if command -v docker-compose &> /dev/null; then 26 | if ! docker-compose -f docker-compose.yml up --build; then 27 | echo "Error: Failed to start services" 28 | exit 1 29 | fi 30 | else 31 | if ! docker compose -f docker-compose.yml up --build; then 32 | echo "Error: Failed to start services" 33 | exit 1 34 | fi 35 | fi 36 | 37 | echo "" 38 | echo "Services started successfully!" 39 | echo "Frontend: http://localhost:3000" 40 | echo "Backend API: http://localhost:8080" 41 | echo "" 42 | echo "To stop the services, press Ctrl+C or run: (cd docker && docker compose down)" 43 | -------------------------------------------------------------------------------- /frontend/src/components/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Generic Error Boundary to isolate runtime errors in lazily loaded pages like JarDetails 4 | class ErrorBoundary extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | this.state = { hasError: false, error: null }; 8 | } 9 | 10 | static getDerivedStateFromError(error) { 11 | return { hasError: true, error }; 12 | } 13 | 14 | componentDidCatch(error, errorInfo) { 15 | // eslint-disable-next-line no-console 16 | console.error('ErrorBoundary caught an error:', error, errorInfo); 17 | } 18 | 19 | handleRetry = () => { 20 | this.setState({ hasError: false, error: null }); 21 | }; 22 | 23 | render() { 24 | if (this.state.hasError) { 25 | return ( 26 |
27 | 28 |

Failed to render component

29 |

{this.state.error && (this.state.error.message || String(this.state.error))}

30 | 31 |
32 | ); 33 | } 34 | return this.props.children; 35 | } 36 | } 37 | 38 | export default ErrorBoundary; 39 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/service/ApplicationService.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.server.service; 2 | 3 | import java.util.Map; 4 | import java.util.concurrent.ConcurrentHashMap; 5 | 6 | import io.github.brunoborges.jlib.common.JavaApplication; 7 | 8 | import java.util.Collection; 9 | 10 | /** 11 | * Service for managing Java application data. 12 | */ 13 | public class ApplicationService { 14 | 15 | /** In-memory storage for tracked Java applications */ 16 | private final Map applications = new ConcurrentHashMap<>(); 17 | 18 | /** 19 | * Gets or creates an application. 20 | */ 21 | public JavaApplication getOrCreateApplication(String appId, String commandLine, 22 | String jdkVersion, String jdkVendor, String jdkPath) { 23 | return applications.computeIfAbsent(appId, 24 | id -> new JavaApplication(id, commandLine, jdkVersion, jdkVendor, jdkPath)); 25 | } 26 | 27 | /** 28 | * Gets an application by ID. 29 | */ 30 | public JavaApplication getApplication(String appId) { 31 | return applications.get(appId); 32 | } 33 | 34 | /** 35 | * Gets all applications. 36 | */ 37 | public Collection getAllApplications() { 38 | return applications.values(); 39 | } 40 | 41 | /** 42 | * Gets the number of tracked applications. 43 | */ 44 | public int getApplicationCount() { 45 | return applications.size(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /sample-spring-app/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4.1.0 3 | 4 | 5 | io.github.brunoborges 6 | jlib-inspector 7 | 1.0.1-SNAPSHOT 8 | ../ 9 | 10 | 11 | sample-spring-app 12 | sample-spring-app 13 | 14 | 15 | 21 16 | 21 17 | 18 | 19 | 20 | 21 | org.springframework.boot 22 | spring-boot-starter 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | org.springframework.boot 32 | spring-boot-maven-plugin 33 | 34 | 35 | 36 | repackage 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /docs/assets/js/main.js: -------------------------------------------------------------------------------- 1 | // Simple dark/light toggle persisted in localStorage 2 | (() => { 3 | const key = 'jlib-theme'; 4 | const btn = document.getElementById('themeToggle'); 5 | const apply = (m) => { document.body.classList.remove('theme-dark','theme-light'); document.body.classList.add(m); }; 6 | const stored = localStorage.getItem(key) || 'theme-dark'; 7 | apply(stored); 8 | if (btn) { 9 | btn.addEventListener('click', () => { 10 | const next = document.body.classList.contains('theme-dark') ? 'theme-light' : 'theme-dark'; 11 | apply(next); localStorage.setItem(key, next); 12 | }); 13 | } 14 | })(); 15 | 16 | // Simple lightbox for screenshots 17 | (() => { 18 | const gallery = document.querySelector('[data-gallery]'); 19 | const lb = document.getElementById('lightbox'); 20 | if (!gallery || !lb) return; 21 | const imgEl = lb.querySelector('img'); 22 | const capEl = lb.querySelector('.caption'); 23 | const closeBtn = lb.querySelector('.close'); 24 | const open = (src, cap) => { imgEl.src = src; capEl.textContent = cap || ''; lb.hidden = false; document.body.style.overflow='hidden'; }; 25 | const close = () => { lb.hidden = true; imgEl.src=''; document.body.style.overflow=''; }; 26 | gallery.addEventListener('click', e => { 27 | const t = e.target.closest('img'); 28 | if (!t) return; 29 | open(t.dataset.full || t.src, t.closest('figure')?.querySelector('figcaption')?.textContent); 30 | }); 31 | closeBtn?.addEventListener('click', close); 32 | lb.addEventListener('click', e => { if (e.target === lb) close(); }); 33 | window.addEventListener('keydown', e => { if (e.key === 'Escape' && !lb.hidden) close(); }); 34 | })(); 35 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% if page.title %}{{ page.title }} · {% endif %}{{ site.title }} 8 | {%- seo -%} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 34 | 35 |
{{ content }}
36 | 37 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /common/src/main/java/io/github/brunoborges/jlib/common/JavaApplication.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.common; 2 | 3 | import java.time.Instant; 4 | import java.util.Map; 5 | import java.util.concurrent.ConcurrentHashMap; 6 | 7 | /** 8 | * Represents a tracked Java application with its metadata and JAR inventory. 9 | */ 10 | public class JavaApplication { 11 | public final String appId; 12 | public final String commandLine; 13 | public final String jdkVersion; 14 | public final String jdkVendor; 15 | public final String jdkPath; 16 | public final Instant firstSeen; 17 | public volatile Instant lastUpdated; 18 | public final Map jars = new ConcurrentHashMap<>(); 19 | // Editable metadata 20 | public volatile String name; 21 | public volatile String description; 22 | public final java.util.List tags = new java.util.concurrent.CopyOnWriteArrayList<>(); 23 | // JVM details JSON (as received from agent) stored verbatim 24 | public volatile String jvmDetails; // nullable 25 | 26 | public JavaApplication(String appId, String commandLine, String jdkVersion, 27 | String jdkVendor, String jdkPath) { 28 | this.appId = appId; 29 | this.commandLine = commandLine; 30 | this.jdkVersion = jdkVersion; 31 | this.jdkVendor = jdkVendor; 32 | this.jdkPath = jdkPath; 33 | this.firstSeen = Instant.now(); 34 | this.lastUpdated = this.firstSeen; 35 | } 36 | 37 | public void updateLastSeen() { 38 | this.lastUpdated = Instant.now(); 39 | } 40 | 41 | public void setName(String name) { 42 | this.name = name; 43 | } 44 | 45 | public void setDescription(String description) { 46 | this.description = description; 47 | } 48 | 49 | public void setTags(java.util.List newTags) { 50 | this.tags.clear(); 51 | if (newTags != null) this.tags.addAll(newTags); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/handler/HealthHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.server.handler; 2 | 3 | import com.sun.net.httpserver.HttpExchange; 4 | import com.sun.net.httpserver.HttpHandler; 5 | 6 | import io.github.brunoborges.jlib.json.JsonResponseBuilder; 7 | import io.github.brunoborges.jlib.server.service.ApplicationService; 8 | 9 | import java.io.IOException; 10 | import java.io.OutputStream; 11 | import java.nio.charset.StandardCharsets; 12 | 13 | /** 14 | * HTTP handler for /health endpoint. 15 | */ 16 | public class HealthHandler implements HttpHandler { 17 | 18 | private final ApplicationService applicationService; 19 | 20 | public HealthHandler(ApplicationService applicationService) { 21 | this.applicationService = applicationService; 22 | } 23 | 24 | @Override 25 | public void handle(HttpExchange exchange) throws IOException { 26 | if ("GET".equals(exchange.getRequestMethod())) { 27 | String response = JsonResponseBuilder.buildHealthJson(applicationService.getApplicationCount()); 28 | sendJsonResponse(exchange, 200, response); 29 | } else { 30 | sendResponse(exchange, 405, "Method not allowed"); 31 | } 32 | } 33 | 34 | private void sendResponse(HttpExchange exchange, int statusCode, String response) throws IOException { 35 | byte[] responseBytes = response.getBytes(StandardCharsets.UTF_8); 36 | exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8"); 37 | exchange.sendResponseHeaders(statusCode, responseBytes.length); 38 | try (OutputStream os = exchange.getResponseBody()) { 39 | os.write(responseBytes); 40 | } 41 | } 42 | 43 | private void sendJsonResponse(HttpExchange exchange, int statusCode, String jsonResponse) throws IOException { 44 | byte[] responseBytes = jsonResponse.getBytes(StandardCharsets.UTF_8); 45 | exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); 46 | exchange.sendResponseHeaders(statusCode, responseBytes.length); 47 | try (OutputStream os = exchange.getResponseBody()) { 48 | os.write(responseBytes); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /common/src/main/java/io/github/brunoborges/jlib/common/ApplicationIdUtil.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.common; 2 | 3 | import java.nio.charset.StandardCharsets; 4 | import java.security.MessageDigest; 5 | import java.util.List; 6 | 7 | /** 8 | * Utility class for computing application IDs. 9 | */ 10 | public class ApplicationIdUtil { 11 | 12 | /** 13 | * Computes a hash ID for a Java application based on command line and JAR 14 | * checksums. 15 | * 16 | * @param commandLine The full JVM command line 17 | * @param jarChecksums List of checksums for all JARs in the command line 18 | * @param jdkVersion JDK version string 19 | * @param jdkVendor JDK vendor string 20 | * @param jdkPath JDK installation path 21 | * @return SHA-256 hash ID representing this unique application configuration 22 | */ 23 | public static String computeApplicationId(String commandLine, List jarChecksums, 24 | String jdkVersion, String jdkVendor, String jdkPath) { 25 | try { 26 | MessageDigest digest = MessageDigest.getInstance("SHA-256"); 27 | 28 | // Include all identifying information in the hash 29 | digest.update(commandLine.getBytes(StandardCharsets.UTF_8)); 30 | digest.update(jdkVersion.getBytes(StandardCharsets.UTF_8)); 31 | digest.update(jdkVendor.getBytes(StandardCharsets.UTF_8)); 32 | digest.update(jdkPath.getBytes(StandardCharsets.UTF_8)); 33 | 34 | // Include JAR checksums in sorted order for consistency 35 | jarChecksums.stream() 36 | .sorted() 37 | .forEach(checksum -> digest.update(checksum.getBytes(StandardCharsets.UTF_8))); 38 | 39 | // Convert to hex string 40 | byte[] hashBytes = digest.digest(); 41 | StringBuilder hexString = new StringBuilder(); 42 | for (byte b : hashBytes) { 43 | String hex = Integer.toHexString(0xff & b); 44 | if (hex.length() == 1) { 45 | hexString.append('0'); 46 | } 47 | hexString.append(hex); 48 | } 49 | return hexString.toString(); 50 | 51 | } catch (Exception e) { 52 | throw new RuntimeException("Failed to compute application ID", e); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | // Utility functions for JLib Inspector Dashboard 2 | 3 | export const formatFileSize = (bytes) => { 4 | if (bytes === 0 || bytes === -1) return 'Unknown'; 5 | const k = 1024; 6 | const sizes = ['Bytes', 'KB', 'MB', 'GB']; 7 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 8 | return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; 9 | }; 10 | 11 | export const formatRelativeTime = (dateString) => { 12 | if (!dateString) return 'Never'; 13 | 14 | const now = new Date(); 15 | const date = new Date(dateString); 16 | const diffInSeconds = Math.floor((now - date) / 1000); 17 | 18 | if (diffInSeconds < 60) return 'Just now'; 19 | if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; 20 | if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; 21 | return `${Math.floor(diffInSeconds / 86400)}d ago`; 22 | }; 23 | 24 | export const extractJarNameForSearch = (fileName) => { 25 | if (!fileName || fileName.endsWith('.class') || fileName === '') { 26 | return null; 27 | } 28 | 29 | // Skip system JARs (JRT modules) 30 | if (fileName.startsWith('jdk.') || fileName.startsWith('java.')) { 31 | return null; 32 | } 33 | 34 | // Remove .jar extension 35 | let name = fileName.replace(/\.jar$/, ''); 36 | 37 | // Common version patterns to remove 38 | // Pattern 1: name-1.2.3 or name-1.2.3-SNAPSHOT 39 | name = name.replace(/-\d+(?:\.\d+)*(?:-[A-Z]+)?$/i, ''); 40 | 41 | // Pattern 2: name-1.2.3.RELEASE or similar 42 | name = name.replace(/-\d+(?:\.\d+)*\.[A-Z]+$/i, ''); 43 | 44 | // Pattern 3: name_1_2_3 (underscore versions) 45 | name = name.replace(/_\d+(?:_\d+)*$/, ''); 46 | 47 | return name || null; 48 | }; 49 | 50 | export const getMvnRepositoryUrl = (fileName) => { 51 | const searchName = extractJarNameForSearch(fileName); 52 | if (!searchName) return null; 53 | return `https://mvnrepository.com/search?q=${encodeURIComponent(searchName)}`; 54 | }; 55 | 56 | export const copyToClipboard = async (text, callback) => { 57 | try { 58 | await navigator.clipboard.writeText(text); 59 | if (callback) callback(true); 60 | return true; 61 | } catch (err) { 62 | console.error('Failed to copy text: ', err); 63 | if (callback) callback(false); 64 | return false; 65 | } 66 | }; 67 | 68 | export const initLucideIcons = () => { 69 | if (window.lucide) { 70 | window.lucide.createIcons(); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /common/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4.1.0 3 | 4 | io.github.brunoborges 5 | jlib-inspector 6 | 1.0.1-SNAPSHOT 7 | ../ 8 | 9 | 10 | jlib-inspector-common 11 | jlib-inspector-common 12 | 13 | 14 | 21 15 | 16 | 17 | 18 | 19 | org.json 20 | json 21 | 22 | 23 | 24 | 25 | org.junit.jupiter 26 | junit-jupiter 27 | test 28 | 29 | 30 | 31 | 32 | 33 | 34 | org.apache.maven.plugins 35 | maven-jar-plugin 36 | 37 | 38 | 39 | 40 | org.apache.maven.plugins 41 | maven-surefire-plugin 42 | 43 | 44 | **/*Test.java 45 | **/*Tests.java 46 | 47 | 48 | 49 | 50 | 51 | 52 | org.jacoco 53 | jacoco-maven-plugin 54 | 55 | 56 | 57 | prepare-agent 58 | 59 | 60 | 61 | report 62 | test 63 | 64 | report 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /server/server.http: -------------------------------------------------------------------------------- 1 | ### --------------------------------------------------------------------------- 2 | ### JLib Inspector – HTTP Request Collection 3 | ### Use with VS Code REST Client (humao.rest-client) or similar tools. 4 | ### Set variables below as needed. 5 | ### --------------------------------------------------------------------------- 6 | 7 | @base = http://localhost:8080 8 | @appId = 56797b7d72774f0cecbe7533935fac1e08b0fe7c1749bd5cca4ab0d2b1652597 9 | 10 | ### 1. Healthcheck 11 | GET {{base}}/health 12 | 13 | ### 2. List all applications (summary) 14 | GET {{base}}/api/apps 15 | 16 | ### 3. Get full application detail (includes jars without manifest) 17 | GET {{base}}/api/apps/{{appId}} 18 | 19 | ### 4. List only jars of a given application ID (lighter than full detail) 20 | GET {{base}}/api/apps/{{appId}}/jars 21 | 22 | ### 5. Upsert (create/update) an application and its jar inventory 23 | # Typically performed by the Java agent. Provide realistic values if calling manually. 24 | PUT {{base}}/api/apps/{{appId}} 25 | Content-Type: application/json 26 | 27 | { 28 | "commandLine": "java -jar sample-spring-app-1.0-SNAPSHOT.jar", 29 | "jdkVersion": "21.0.2", 30 | "jdkVendor": "Eclipse Adoptium", 31 | "jdkPath": "/usr/lib/jvm/jdk-21", 32 | "jars": [ 33 | { 34 | "path": "file:/app/sample-spring-app-1.0-SNAPSHOT.jar", 35 | "fileName": "sample-spring-app-1.0-SNAPSHOT.jar", 36 | "size": 1234567, 37 | "checksum": "2f54deadbeefcafefeed112233aabbccddeeff00112233445566778899aabbcc", 38 | "loaded": true 39 | }, 40 | { 41 | "path": "file:/app/lib/spring-core-6.1.0.jar", 42 | "fileName": "spring-core-6.1.0.jar", 43 | "size": 543210, 44 | "checksum": "?", 45 | "loaded": true 46 | } 47 | ] 48 | } 49 | 50 | ### 6. Update application metadata only (name / description / tags) 51 | PUT {{base}}/api/apps/{{appId}}/metadata 52 | Content-Type: application/json 53 | 54 | { 55 | "name": "Sample Spring App", 56 | "description": "Demo application for JLib Inspector", 57 | "tags": ["demo", "spring", "sample"] 58 | } 59 | ### 9. Global JAR list (deduplicated) 60 | GET {{base}}/api/jars 61 | 62 | ### 10. JAR detail by jarId 63 | @jarId = ed3ef4e4641e91ab8d880dccc670d7111759e33aac8c8482722f2f62b45a84fc 64 | GET {{base}}/api/jars/{{jarId}} 65 | 66 | ### Test Frontend localhost:3000 67 | GET http://localhost:3000/api/jars/{{jarId}} 68 | 69 | ### --------------------------------------------------------------------------- 70 | ### Tips: 71 | ### - Run the sample app with the agent to populate data. 72 | ### - Change @appId after listing applications. 73 | ### - PUT endpoints return plain text or full JSON (metadata update) per API docs. 74 | ### --------------------------------------------------------------------------- 75 | 76 | ### Get JVM details from an app 77 | GET {{base}}/api/apps/{{appId}}/jvm 78 | 79 | 80 | ### 7. Dashboard snapshot (all apps + jars in one payload) 81 | GET {{base}}/api/dashboard 82 | 83 | ### 8. Unique loaded JARs report (aggregation across apps) 84 | GET {{base}}/report 85 | 86 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # JLib Inspector - Docker Setup 2 | 3 | This document explains how to run the JLib Inspector using Docker Compose. 4 | 5 | ## Prerequisites 6 | 7 | - Docker 8 | - Docker Compose 9 | 10 | ## Quick Start 11 | 12 | 1. **Start the services:** 13 | ```bash 14 | ./start-docker.sh 15 | ``` 16 | 17 | Or manually: 18 | ```bash 19 | docker-compose up --build 20 | ``` 21 | 22 | 2. **Access the application:** 23 | - Frontend Dashboard: http://localhost:3000 24 | - Backend API: http://localhost:8080 25 | 26 | 3. **Stop the services:** 27 | ```bash 28 | docker-compose down 29 | ``` 30 | 31 | ## Services 32 | 33 | ### JLib Server (Backend) 34 | - **Port:** 8080 35 | - **Health Check:** http://localhost:8080/health 36 | - **API Endpoints:** http://localhost:8080/api/apps 37 | 38 | ### Frontend Dashboard 39 | - **Port:** 3000 40 | - **Built with:** Node.js, Express, React 41 | - **Connects to:** JLib Server at http://jlib-server:8080 42 | 43 | ## Docker Configuration 44 | 45 | ### Backend (Dockerfile.backend) 46 | - Base image: OpenJDK 21 47 | - Builds Maven project 48 | - Exposes port 8080 49 | - Includes health check with curl 50 | 51 | ### Frontend (Dockerfile.frontend) 52 | - Base image: Node.js 18 Alpine 53 | - Builds React application with Webpack 54 | - Serves via Express on port 3000 55 | - Configured to connect to backend service 56 | 57 | ### Networking 58 | - Both services run on a custom bridge network (`jlib-network`) 59 | - Frontend connects to backend using service name `jlib-server` 60 | - Ports are bound to host: 3000 (frontend) and 8080 (backend) 61 | 62 | ## Environment Variables 63 | 64 | ### Backend 65 | - `JAVA_OPTS`: JVM options (default: `-Xms512m -Xmx1g`) 66 | 67 | ### Frontend 68 | - `PORT`: Frontend server port (default: 3000) 69 | - `JLIB_SERVER_URL`: Backend URL (default: `http://jlib-server:8080`) 70 | 71 | ## Logs 72 | 73 | View logs for specific services: 74 | ```bash 75 | # Backend logs 76 | docker-compose logs jlib-server 77 | 78 | # Frontend logs 79 | docker-compose logs jlib-frontend 80 | 81 | # All logs 82 | docker-compose logs 83 | ``` 84 | 85 | ## Troubleshooting 86 | 87 | ### Backend not starting 88 | - Check if port 8080 is already in use 89 | - Verify Java 21 compatibility 90 | - Check build logs: `docker-compose logs jlib-server` 91 | 92 | ### Frontend not connecting to backend 93 | - Ensure backend is healthy: `curl http://localhost:8080/health` 94 | - Check network connectivity between containers 95 | - Verify environment variable `JLIB_SERVER_URL` 96 | 97 | ### Build issues 98 | - Clean and rebuild: `docker-compose down && docker-compose up --build --force-recreate` 99 | - Check available disk space 100 | - Verify all source files are present 101 | 102 | ## Development 103 | 104 | For development with auto-reload: 105 | ```bash 106 | # Build images once 107 | docker-compose build 108 | 109 | # Start with logs 110 | docker-compose up 111 | 112 | # In another terminal, make changes to source code 113 | # Rebuild specific service: 114 | docker-compose build jlib-frontend 115 | docker-compose up -d jlib-frontend 116 | ``` 117 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Copilot instructions – Build, test, and run 2 | 3 | This repo has three main parts: 4 | - Java modules: `common`, `server` (backend), `agent` (Java agent) 5 | - Node.js app: `frontend` (dashboard) 6 | 7 | Tooling and versions: 8 | - Java 21+, Maven 4+ (use `./mvnw`), Node 18+ 9 | - Default ports: server 8080, dashboard 3000 (HTTP), 3001 (WS) 10 | 11 | ## Build 12 | 13 | - Full build (root aggregator): 14 | - `./mvnw -q clean verify` 15 | 16 | - Module builds: 17 | - Server only: `./mvnw -P server package` 18 | - Agent only: `./mvnw -P agent package` 19 | 20 | Artifacts (shaded JARs): 21 | - Agent: `agent/target/jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar` 22 | - Server: `server/target/jlib-inspector-server-1.0-SNAPSHOT-shaded.jar` 23 | 24 | Notes: 25 | - Maven Enforcer requires Maven 4+; always use the wrapper (`./mvnw`). 26 | - Java 21 is required (`maven.compiler.release=21`). 27 | - JaCoCo is enabled; reports: `target/site/jacoco/` per module. 28 | 29 | ## Build - frontend 30 | 31 | From `frontend/`: 32 | 33 | ```bash 34 | npm install # or: npm ci 35 | npm run build # builds the frontend 36 | ``` 37 | 38 | ## Run – backend (server) 39 | 40 | Start the HTTP server (port optional, default used here is 8080): 41 | 42 | ```bash 43 | java -jar server/target/jlib-inspector-server-1.0-SNAPSHOT-shaded.jar 8080 44 | ``` 45 | 46 | Health and APIs: 47 | - `GET /health` 48 | - `GET /api/apps` 49 | - `PUT /api/apps/{appId}` 50 | 51 | ## Run – agent (attach to any Java app) 52 | 53 | Use the shaded agent jar and point to the server: 54 | 55 | ```bash 56 | java -javaagent:agent/target/jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar=server:8080 -jar your-app.jar 57 | ``` 58 | 59 | Sample app (included): 60 | 61 | ```bash 62 | java -javaagent:agent/target/jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar=server:8080 -jar sample-spring-app/target/sample-spring-app-1.0-SNAPSHOT.jar 63 | ``` 64 | 65 | ## Run – frontend (dashboard) 66 | 67 | npm and node commands must always run in the frontend folder. 68 | 69 | From `frontend/`: 70 | 71 | ```bash 72 | npm install # or: npm ci 73 | npm start # builds and starts Express on port 3000 74 | ``` 75 | 76 | Environment: 77 | - `JLIB_SERVER_URL` (default: `http://localhost:8080`) 78 | - `PORT` (default: `3000`), `WS_PORT` (default: `3001`) 79 | 80 | ## Tests 81 | 82 | - Java unit tests: `./mvnw test` (root or module) 83 | - Frontend: current `test` script is a no-op 84 | 85 | ## Docker (optional) 86 | 87 | Run both backend and frontend with Docker Compose from repo root: 88 | 89 | ```bash 90 | ./docker/start-docker.sh 91 | ``` 92 | 93 | Then: 94 | - Frontend: http://localhost:3000 95 | - Backend: http://localhost:8080/health 96 | 97 | Stop: 98 | 99 | ```bash 100 | cd docker && docker compose down 101 | ``` 102 | 103 | ## Codespaces 104 | 105 | - Devcontainer installs Java 21, Maven, Node 18 and pre-builds the project. 106 | - Useful tasks (Run Task): Build (Maven), Start Server, Start Frontend, Run Sample App (with agent). 107 | 108 | ## Troubleshooting 109 | 110 | - “Command not found” Maven or version mismatch → use `./mvnw`. 111 | - Java < 21 → install JDK 21; verify `java -version`. 112 | - Shaded jar missing → ensure `package` phase completed and see target paths above. 113 | - Frontend cannot reach backend → check `JLIB_SERVER_URL` and server health endpoint. 114 | 115 | -------------------------------------------------------------------------------- /frontend/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | /* Global styles for JLib Inspector Dashboard */ 2 | 3 | .fade-in { 4 | animation: fadeIn 0.5s ease-in; 5 | } 6 | 7 | .slide-in { 8 | animation: slideIn 0.3s ease-out; 9 | } 10 | 11 | @keyframes fadeIn { 12 | from { 13 | opacity: 0; 14 | transform: translateY(10px); 15 | } 16 | to { 17 | opacity: 1; 18 | transform: translateY(0); 19 | } 20 | } 21 | 22 | @keyframes slideIn { 23 | from { 24 | opacity: 0; 25 | transform: translateX(100%); 26 | } 27 | to { 28 | opacity: 1; 29 | transform: translateX(0); 30 | } 31 | } 32 | 33 | .status-connected { 34 | color: #10b981; 35 | } 36 | 37 | .status-disconnected { 38 | color: #ef4444; 39 | } 40 | 41 | .status-unknown { 42 | color: #6b7280; 43 | } 44 | 45 | .card { 46 | background: white; 47 | border-radius: 12px; 48 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); 49 | border: 1px solid #e5e7eb; 50 | transition: all 0.2s ease-in-out; 51 | } 52 | 53 | .card:hover { 54 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 55 | transform: translateY(-1px); 56 | } 57 | 58 | .app-card { 59 | cursor: pointer; 60 | transition: all 0.2s ease-in-out; 61 | } 62 | 63 | .app-card:hover { 64 | border-color: #3b82f6; 65 | transform: translateY(-2px); 66 | } 67 | 68 | .jar-item { 69 | transition: all 0.2s ease-in-out; 70 | } 71 | 72 | .jar-item:hover { 73 | background-color: #f8fafc; 74 | transform: translateX(4px); 75 | } 76 | 77 | .search-input { 78 | transition: all 0.2s ease-in-out; 79 | } 80 | 81 | .search-input:focus { 82 | transform: scale(1.02); 83 | } 84 | 85 | .modal-overlay { 86 | background: rgba(0, 0, 0, 0.5); 87 | backdrop-filter: blur(4px); 88 | } 89 | 90 | .grid-view { 91 | display: grid; 92 | grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); 93 | gap: 1.5rem; 94 | } 95 | 96 | .list-view .app-card { 97 | margin-bottom: 1rem; 98 | } 99 | 100 | .compact-jar { 101 | border-left: 3px solid #e5e7eb; 102 | transition: border-color 0.2s ease-in-out; 103 | } 104 | 105 | .compact-jar.loaded { 106 | border-left-color: #10b981; 107 | } 108 | 109 | .compact-jar.not-loaded { 110 | border-left-color: #ef4444; 111 | } 112 | 113 | .tab-button { 114 | transition: all 0.2s ease-in-out; 115 | } 116 | 117 | .tab-button.active { 118 | background-color: #3b82f6; 119 | color: white; 120 | border-color: #3b82f6; 121 | } 122 | 123 | .tab-button:not(.active) { 124 | background-color: white; 125 | color: #6b7280; 126 | border-color: #d1d5db; 127 | } 128 | 129 | .tab-button:not(.active):hover { 130 | background-color: #f9fafb; 131 | color: #374151; 132 | } 133 | 134 | .tab-content { 135 | display: none; 136 | } 137 | 138 | .tab-content.active { 139 | display: block; 140 | } 141 | 142 | /* Loading spinner */ 143 | .spinner { 144 | border: 4px solid #f3f4f6; 145 | border-top: 4px solid #3b82f6; 146 | border-radius: 50%; 147 | width: 3rem; 148 | height: 3rem; 149 | animation: spin 1s linear infinite; 150 | } 151 | 152 | @keyframes spin { 153 | 0% { transform: rotate(0deg); } 154 | 100% { transform: rotate(360deg); } 155 | } 156 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # JLib Inspector Dashboard 2 | 3 | > **⚠️ EXPERIMENTAL - NOT PRODUCTION READY** 4 | > This software is in development and should only be used for development, testing, and evaluation purposes. 5 | 6 | A simplified single Node.js application for monitoring Java applications and their JAR dependencies. 7 | 8 | ## Features 9 | 10 | - **Real-time Monitoring**: Live updates via WebSocket connection 11 | - **Application Overview**: View all monitored Java applications 12 | - **JAR Dependencies**: Detailed view of JAR files and their loading status 13 | - **Statistics Dashboard**: Summary statistics about applications and JARs 14 | - **Responsive UI**: Clean, modern interface built with Tailwind CSS 15 | 16 | ## Getting Started 17 | 18 | ### Prerequisites 19 | 20 | - Node.js 14.0.0 or higher 21 | - JLib Server running on port 8080 (or custom port via environment variable) 22 | 23 | ### Installation 24 | 25 | 1. Install dependencies: 26 | ```bash 27 | npm install 28 | ``` 29 | 30 | 2. Start the dashboard: 31 | ```bash 32 | npm start 33 | ``` 34 | 35 | For development with auto-restart: 36 | ```bash 37 | npm run dev 38 | ``` 39 | 40 | 3. Open your browser and navigate to: 41 | ``` 42 | http://localhost:3000 43 | ``` 44 | 45 | ### Configuration 46 | 47 | The dashboard can be configured using environment variables: 48 | 49 | - `PORT`: Dashboard server port (default: 3000) 50 | - `JLIB_SERVER_URL`: JLib Server URL (default: http://localhost:8080) 51 | 52 | Example: 53 | ```bash 54 | PORT=8080 JLIB_SERVER_URL=http://localhost:9090 npm start 55 | ``` 56 | 57 | ## Architecture 58 | 59 | This is a simplified single-application architecture that combines: 60 | 61 | - **Express.js Server**: Serves the web interface and provides API endpoints 62 | - **WebSocket Server**: Provides real-time updates to connected clients 63 | - **Static File Serving**: Serves the dashboard UI directly 64 | - **JLib Server Integration**: Fetches data from the JLib Server every 10 seconds 65 | 66 | ### Endpoints 67 | 68 | - `GET /`: Dashboard web interface 69 | - `GET /api/dashboard`: Complete dashboard data (JSON) 70 | - `GET /api/applications`: List of monitored applications (JSON) 71 | - `GET /api/applications/:appId`: Specific application details (JSON) 72 | - `GET /api/health`: Dashboard health status (JSON) 73 | - `WebSocket ws://localhost:3001`: Real-time updates 74 | 75 | ## Technology Stack 76 | 77 | - **Backend**: Node.js, Express.js, WebSocket 78 | - **Frontend**: Vanilla JavaScript, Tailwind CSS, Lucide Icons 79 | - **Data Fetching**: Axios, node-cron 80 | - **Real-time Updates**: WebSocket 81 | 82 | ## Development 83 | 84 | The application automatically: 85 | - Fetches data from JLib Server every 10 seconds 86 | - Broadcasts updates to all connected WebSocket clients 87 | - Handles JLib Server disconnections gracefully 88 | - Provides loading states and error handling 89 | 90 | ## Previous Architecture 91 | 92 | This replaces the previous dual-application setup that had: 93 | - Separate React client application 94 | - Separate Node.js server application 95 | - Complex build process and dependency management 96 | 97 | The new simplified architecture provides the same functionality with: 98 | - Single application to deploy and manage 99 | - No build process required 100 | - Reduced complexity and dependencies 101 | - Better performance and reliability 102 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: Release (agent & server) 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v*.*.*" # tags created by maven-release-plugin, e.g., v1.2.3 8 | 9 | permissions: 10 | contents: write # needed to create/modify the GitHub Release 11 | 12 | concurrency: 13 | group: release-${{ github.ref }} 14 | cancel-in-progress: false 15 | 16 | jobs: 17 | build-and-release: 18 | runs-on: ubuntu-latest 19 | 20 | env: 21 | # Tag like v1.2.3 -> version 1.2.3 22 | RELEASE_TAG: ${{ github.ref_name }} 23 | 24 | steps: 25 | - name: Checkout the tag 26 | uses: actions/checkout@v4 27 | with: 28 | # ensure we build exactly the tag content 29 | ref: ${{ github.ref }} 30 | fetch-depth: 0 31 | 32 | - name: Set up Temurin JDK 33 | uses: actions/setup-java@v4 34 | with: 35 | distribution: temurin 36 | java-version: 21 37 | cache: maven 38 | 39 | - name: Build profile release 40 | run: | 41 | ./mvnw -B -P release -DskipTests=false verify 42 | 43 | - name: Collect shaded artifacts 44 | run: | 45 | mkdir -p dist 46 | # Grab only shaded jars from the release-built modules 47 | find . -path "*/target/*-shaded.jar" -print -exec cp {} dist/ \; 48 | 49 | # Sanity: ensure we actually found agent/server shaded jars 50 | ls -l dist 51 | if ! ls dist/*-shaded.jar >/dev/null 2>&1; then 52 | echo "No shaded jars found. Check shade plugin config or profile." 53 | exit 1 54 | fi 55 | 56 | # Create checksums 57 | (cd dist && for f in *-shaded.jar; do sha256sum "$f" > "$f.sha256"; done) 58 | 59 | - name: Create/Update GitHub Release and upload assets 60 | uses: softprops/action-gh-release@v2 61 | with: 62 | tag_name: ${{ env.RELEASE_TAG }} 63 | name: ${{ env.RELEASE_TAG }} 64 | generate_release_notes: true 65 | files: | 66 | dist/*-shaded.jar 67 | dist/*.sha256 68 | 69 | - name: Derive version (strip leading v) 70 | id: version 71 | run: | 72 | RAW="${RELEASE_TAG}" 73 | CLEAN="${RAW#v}" 74 | echo "version=$CLEAN" >> "$GITHUB_OUTPUT" 75 | echo "Derived version: $CLEAN" 76 | 77 | - name: Set up Docker Buildx 78 | uses: docker/setup-buildx-action@v3 79 | 80 | - name: Login to Docker Hub 81 | uses: docker/login-action@v3 82 | with: 83 | username: ${{ secrets.DOCKERHUB_USERNAME }} 84 | password: ${{ secrets.DOCKERHUB_TOKEN }} 85 | 86 | - name: Build & Push Frontend Image 87 | uses: docker/build-push-action@v5 88 | with: 89 | context: . 90 | file: docker/Dockerfile.frontend 91 | platforms: linux/amd64 92 | push: true 93 | tags: | 94 | brunoborges/jlib-frontend:${{ steps.version.outputs.version }} 95 | brunoborges/jlib-frontend:latest 96 | build-args: | 97 | APP_VERSION=${{ steps.version.outputs.version }} 98 | 99 | - name: Summary (docker image) 100 | run: | 101 | echo "## Docker Image" >> $GITHUB_STEP_SUMMARY 102 | echo "Pushed: brunoborges/jlib-frontend:${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY 103 | echo "Also tagged: brunoborges/jlib-frontend:latest" >> $GITHUB_STEP_SUMMARY 104 | 105 | -------------------------------------------------------------------------------- /DOCKER.md: -------------------------------------------------------------------------------- 1 | # JLib Inspector - Docker Guide 2 | 3 | > **⚠️ EXPERIMENTAL - NOT PRODUCTION READY** 4 | > This software is in development and should only be used for development, testing, and evaluation purposes. 5 | 6 | This guide explains how to build and run the JLib Inspector services using Docker and Docker Compose. 7 | 8 | ## Prerequisites 9 | 10 | - Docker Desktop (or Docker Engine) 20.10+ 11 | - Docker Compose v2 (docker compose) or legacy docker-compose 12 | - Internet access for base images and npm/Maven downloads 13 | 14 | ## Project containers 15 | 16 | - Backend (JLib Server) 17 | - Image built from `docker/Dockerfile.backend` 18 | - Runs the shaded server JAR 19 | - Exposes port `8080` 20 | - Health check endpoint: `http://localhost:8080/health` 21 | - Frontend (Dashboard) 22 | - Image built from `docker/Dockerfile.frontend` 23 | - Serves the React app via Express 24 | - Exposes port `3000` (web) and uses `3001` for WebSocket internally 25 | - Connects to backend via `http://jlib-server:8080` 26 | 27 | ## Quick start 28 | 29 | From the repository root: 30 | 31 | ```bash 32 | ./docker/start-docker.sh 33 | ``` 34 | 35 | This script: 36 | - Ensures it runs from the `docker/` folder 37 | - Starts both backend and frontend with `docker compose up --build` 38 | - Works with either `docker compose` or `docker-compose` 39 | 40 | Then open: 41 | - Frontend: http://localhost:3000 42 | - Backend: http://localhost:8080 43 | 44 | Stop the services with Ctrl+C, or in another terminal: 45 | 46 | ```bash 47 | cd docker 48 | # If using compose v2 49 | docker compose down 50 | # If using legacy compose 51 | # docker-compose down 52 | ``` 53 | 54 | ## How the backend image is built 55 | 56 | `docker/Dockerfile.backend` uses multi-module Maven build steps tailored to the current project structure: 57 | 58 | - Copies Maven wrapper and required POM files 59 | - Builds `common` and then `server` module to produce `server/target/jlib-inspector-server-1.0-SNAPSHOT.jar` 60 | - Runs the server with: 61 | 62 | ```bash 63 | java -jar server/target/jlib-inspector-server-1.0-SNAPSHOT.jar 8080 64 | ``` 65 | 66 | Notes: 67 | - The backend builds only `common` and `server` modules to keep the image small. 68 | - The server JAR is shaded, so it contains all runtime deps. 69 | 70 | ## How the frontend image is built 71 | 72 | `docker/Dockerfile.frontend`: 73 | - Installs npm dependencies 74 | - Builds the React app with `npm run build:only` 75 | - Starts Express with `npm start` 76 | 77 | ## Using the Java agent with containers 78 | 79 | The Java agent is not required inside the backend or frontend containers. Use it when running your Java apps (in or out of containers): 80 | 81 | ```bash 82 | java -javaagent:agent/target/jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar=server:8080 -jar sample-spring-app/target/sample-spring-app-1.0-SNAPSHOT.jar 83 | ``` 84 | 85 | If your app runs in a different container/network, point the agent to the backend’s reachable hostname and port. 86 | 87 | ## Troubleshooting 88 | 89 | - Build fails in backend Dockerfile 90 | - Ensure the repo has all modules and the Maven wrapper 91 | - Check that `common/` and `server/` exist and build locally with `./mvnw -pl common,server -am -DskipTests package` 92 | - "No configuration file provided" when starting 93 | - Use `./docker/start-docker.sh` from repo root (it changes into the `docker/` dir and uses the correct compose file) 94 | - Frontend can’t reach backend 95 | - Check backend health: `curl http://localhost:8080/health` 96 | - Verify `JLIB_SERVER_URL` in frontend environment (defaults to `http://jlib-server:8080` inside the compose network) 97 | 98 | ## Clean rebuild 99 | 100 | ```bash 101 | cd docker 102 | docker compose down -v 103 | docker compose build --no-cache 104 | docker compose up 105 | ``` 106 | -------------------------------------------------------------------------------- /benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | RED="\033[31m"; GREEN="\033[32m"; YELLOW="\033[33m"; BLUE="\033[34m"; MAGENTA="\033[35m"; CYAN="\033[36m"; BOLD="\033[1m"; DIM="\033[2m"; RESET="\033[0m" 5 | 6 | echo -e "${CYAN}Building all projects...${RESET}" >&2 7 | ./mvnw -B -q verify > /dev/null 2>&1 8 | echo -e "${GREEN}Build complete.${RESET}" >&2 9 | 10 | die() { echo "[ERROR] $*" >&2; exit 1; } 11 | 12 | resolve_one() { 13 | # $1 = directory, $2 = glob pattern 14 | local dir=$1 15 | local pattern=$2 16 | local result 17 | # Use find to avoid literal glob fallback; pick first match only 18 | result=$(find "$dir" -maxdepth 1 -type f -name "$pattern" | head -n1 || true) 19 | [ -n "$result" ] || die "No jar found matching $dir/$pattern" 20 | echo "$result" 21 | } 22 | 23 | SERVER_JAR=$(resolve_one server/target '*-shaded.jar') 24 | AGENT_JAR=$(resolve_one agent/target '*-shaded.jar') 25 | JAR_APP=$(resolve_one sample-spring-app/target 'sample-spring-app-*.jar') 26 | 27 | echo -e "${CYAN}Using jars:${RESET}" >&2 28 | echo -e " Server: ${DIM}$SERVER_JAR${RESET}" >&2 29 | echo -e " Agent : ${DIM}$AGENT_JAR${RESET}" >&2 30 | echo -e " App : ${DIM}$JAR_APP${RESET}" >&2 31 | 32 | # start the server in the background and send output to dev null 33 | java -jar "$SERVER_JAR" > /dev/null 2>&1 & 34 | SERVER_PID=$! 35 | 36 | trap 'echo "Stopping server..."; kill $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true' EXIT INT TERM 37 | 38 | # give the server some time to start 39 | sleep 3 40 | 41 | now_ms() { perl -MTime::HiRes=time -e 'printf("%d\n", time()*1000)'; } 42 | 43 | declare -a SUMMARY_LABELS=() 44 | declare -a SUMMARY_WALL=() 45 | declare -a SUMMARY_STARTUP=() 46 | 47 | run_case() { 48 | local label=$1 49 | shift 50 | local tmp 51 | tmp=$(mktemp -t jlib-bench-XXXX.out) 52 | local start end dur status 53 | start=$(now_ms) 54 | # Run the Java command capturing all output 55 | java "$@" > "$tmp" 2>&1 || status=$? || true 56 | end=$(now_ms) 57 | dur=$(( end - start )) 58 | # Extract Spring Boot startup line (first occurrence) 59 | local line raw_startup_ms startup_ms="" process_ms="" 60 | line=$(grep -F 'Started DemoApplication in ' "$tmp" | head -n1 || true) 61 | if [ -n "$line" ]; then 62 | # Parse 'Started DemoApplication in X.YYY seconds (process running for Z.ZZZ)' 63 | if [[ $line =~ Started\ DemoApplication\ in\ ([0-9]+\.[0-9]+)\ seconds.*process\ running\ for\ ([0-9]+\.[0-9]+) ]]; then 64 | startup_ms=$(perl -e "printf('%.0f', ${BASH_REMATCH[1]} * 1000)") 65 | process_ms=$(perl -e "printf('%.0f', ${BASH_REMATCH[2]} * 1000)") 66 | fi 67 | fi 68 | 69 | echo 70 | echo -e "${BOLD}=== ${label} ===${RESET}" 71 | echo -e "Wall: ${YELLOW}${dur} ms${RESET}" 72 | if [ -n "$line" ]; then 73 | if [ -n "$startup_ms" ]; then 74 | echo -e "Startup: ${GREEN}${startup_ms} ms${RESET} (process ${DIM}${process_ms} ms${RESET})" 75 | else 76 | echo -e "Startup: ${GREEN}$line${RESET}" 77 | fi 78 | else 79 | echo -e "Startup: ${RED}(line not found)${RESET}" 80 | fi 81 | 82 | SUMMARY_LABELS+=("$label") 83 | SUMMARY_WALL+=("$dur") 84 | SUMMARY_STARTUP+=("${startup_ms:-}") 85 | 86 | rm -f "$tmp" 87 | } 88 | 89 | run_case "1) agent with server:8080" -javaagent:"$AGENT_JAR"=server:8080 -jar "$JAR_APP" 90 | run_case "2) local agent only" -javaagent:"$AGENT_JAR" -jar "$JAR_APP" 91 | run_case "3) no agent" -jar "$JAR_APP" 92 | 93 | echo 94 | echo -e "${MAGENTA}${BOLD}Summary:${RESET}" 95 | printf "%-28s %12s %14s\n" "Case" "Wall(ms)" "Startup(ms)" 96 | printf "%-28s %12s %14s\n" "----------------------------" "--------" "-----------" 97 | for i in "${!SUMMARY_LABELS[@]}"; do 98 | label=${SUMMARY_LABELS[$i]} 99 | wall=${SUMMARY_WALL[$i]} 100 | startup=${SUMMARY_STARTUP[$i]:-?} 101 | printf "%-28s %12s %14s\n" "$label" "$wall" "$startup" 102 | done 103 | 104 | echo 105 | 106 | echo -e "${CYAN}Benchmark complete.${RESET}" >&2 -------------------------------------------------------------------------------- /.github/scripts/create-release-package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | VERSION="${VERSION:-}" 5 | if [[ -z "$VERSION" ]]; then 6 | echo "VERSION env var not set" >&2 7 | exit 1 8 | fi 9 | 10 | echo "Creating release package for version $VERSION" 11 | WORKDIR="release-package" 12 | rm -rf "$WORKDIR" || true 13 | mkdir -p "$WORKDIR/frontend" 14 | 15 | # Copy built artifacts 16 | cp agent/target/jlib-inspector-agent-*.jar "$WORKDIR/" 17 | cp server/target/jlib-inspector-server-*.jar "$WORKDIR/" 18 | cp sample-spring-app/target/sample-spring-app-*.jar "$WORKDIR/" 19 | 20 | # Copy frontend build output and minimal runtime files 21 | cp -r frontend/dist/* "$WORKDIR/frontend/" 22 | cp frontend/app.js "$WORKDIR/frontend/" 23 | cp frontend/package.json "$WORKDIR/frontend/" 24 | 25 | # Documentation & scripts 26 | cp demo-jlib-inspector.ps1 "$WORKDIR/" 27 | cp README.md "$WORKDIR/" 28 | cp LICENSE "$WORKDIR/" 29 | if [[ -f logging.properties ]]; then 30 | cp logging.properties "$WORKDIR/" 31 | fi 32 | 33 | # Generate install.sh 34 | cat > "$WORKDIR/install.sh" <<'EOF' 35 | #!/usr/bin/env bash 36 | set -euo pipefail 37 | 38 | VERSION_PLACEHOLDER="__VERSION__" 39 | echo "=== JLib Inspector Installation ===" 40 | echo "Version: ${VERSION_PLACEHOLDER}" 41 | echo 42 | 43 | if ! command -v java >/dev/null 2>&1; then 44 | echo "❌ Java is required but not installed." >&2 45 | echo "Install Java 21 or later and retry." >&2 46 | exit 1 47 | fi 48 | 49 | JAVA_VERSION_RAW=$(java -version 2>&1 | awk -F '"' '/version/ {print $2}') 50 | JAVA_MAJOR=$(echo "$JAVA_VERSION_RAW" | awk -F '.' '{print $1}') 51 | if [[ "$JAVA_MAJOR" -lt 21 ]]; then 52 | echo "❌ Java 21 or later required. Found $JAVA_VERSION_RAW" >&2 53 | exit 1 54 | fi 55 | echo "✅ Java $JAVA_VERSION_RAW detected" 56 | 57 | if command -v node >/dev/null 2>&1; then 58 | NODE_MAJOR=$(node --version | sed 's/^v//' | cut -d'.' -f1) 59 | if [[ "$NODE_MAJOR" -ge 18 ]]; then 60 | echo "✅ Node.js $(node --version) detected (frontend available)" 61 | HAS_NODE=true 62 | else 63 | echo "⚠️ Node.js 18+ recommended. Found $(node --version)" 64 | HAS_NODE=false 65 | fi 66 | else 67 | echo "⚠️ Node.js not found. Frontend dashboard won't run."; HAS_NODE=false 68 | fi 69 | 70 | echo 71 | echo "Usage:"; echo 72 | echo "1. Run agent with your Java application:"; echo " java -javaagent:jlib-inspector-agent-*.jar -jar your-app.jar"; echo 73 | echo "2. Run with server integration:"; echo " java -javaagent:jlib-inspector-agent-*.jar=server:8080 -jar your-app.jar"; echo 74 | echo "3. Run demo (PowerShell required):"; echo " ./demo-jlib-inspector.ps1"; echo 75 | if [[ "$HAS_NODE" == true ]]; then 76 | echo "4. Start dashboard:"; echo " cd frontend && npm install && npm start"; echo 77 | fi 78 | echo "See README.md for more details." 79 | EOF 80 | 81 | sed -i.bak "s/__VERSION__/$VERSION/g" "$WORKDIR/install.sh" && rm "$WORKDIR/install.sh.bak" 82 | chmod +x "$WORKDIR/install.sh" 83 | 84 | # Generate Windows install.bat 85 | cat > "$WORKDIR/install.bat" <nul 2>&1 || (echo Java 21+ required & pause & exit /b 1) 91 | echo Java detected 92 | node --version >nul 2>&1 && (echo Node.js detected (frontend available) & set HAS_NODE=true) || (echo Node.js not found. Frontend unavailable. & set HAS_NODE=false) 93 | echo. 94 | echo 1. Run agent: java -javaagent:jlib-inspector-agent-*.jar -jar your-app.jar 95 | echo 2. With server: java -javaagent:jlib-inspector-agent-*.jar=server:8080 -jar your-app.jar 96 | echo 3. Demo: .\demo-jlib-inspector.ps1 97 | if "%HAS_NODE%"=="true" echo 4. Frontend: cd frontend && npm install && npm start 98 | echo. 99 | echo See README.md for more details. 100 | pause 101 | EOF 102 | 103 | # Create archives 104 | tar -czf jlib-inspector-${VERSION}-linux.tar.gz -C "$WORKDIR" . 105 | tar -czf jlib-inspector-${VERSION}-windows.tar.gz -C "$WORKDIR" . 106 | tar -czf jlib-inspector-${VERSION}.tar.gz -C "$WORKDIR" . 107 | 108 | echo "Release archives created:" 109 | ls -1 jlib-inspector-${VERSION}*.tar.gz 110 | -------------------------------------------------------------------------------- /frontend/src/hooks/useDashboardData.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | 3 | export const useDashboardData = () => { 4 | const [dashboardData, setDashboardData] = useState({ 5 | applicationCount: 0, 6 | jarCount: 0, 7 | activeJarCount: 0, 8 | inactiveJarCount: 0, 9 | applications: [], 10 | lastUpdated: null, 11 | serverStatus: 'unknown' 12 | }); 13 | 14 | const [isLoading, setIsLoading] = useState(true); 15 | const [error, setError] = useState(null); 16 | const [ws, setWs] = useState(null); 17 | 18 | // Fetch initial data 19 | const fetchInitialData = useCallback(async () => { 20 | try { 21 | // Add cache-busting timestamp and headers 22 | const timestamp = new Date().getTime(); 23 | const response = await fetch(`/api/dashboard?_t=${timestamp}`, { 24 | method: 'GET', 25 | headers: { 26 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 27 | 'Pragma': 'no-cache', 28 | 'Expires': '0' 29 | } 30 | }); 31 | if (response.ok) { 32 | const data = await response.json(); 33 | setDashboardData(data); 34 | setError(null); 35 | } else { 36 | throw new Error('Failed to fetch data'); 37 | } 38 | } catch (error) { 39 | console.error('Error fetching initial data:', error); 40 | setError(error.message); 41 | } finally { 42 | setIsLoading(false); 43 | } 44 | }, []); 45 | 46 | // Setup WebSocket connection 47 | const setupWebSocket = useCallback(() => { 48 | const websocket = new WebSocket('ws://localhost:3001'); 49 | 50 | websocket.onopen = () => { 51 | console.log('WebSocket connected'); 52 | // Don't override serverStatus here - it should come from the backend 53 | }; 54 | 55 | websocket.onmessage = (event) => { 56 | try { 57 | const message = JSON.parse(event.data); 58 | if (message.type === 'data-update') { 59 | setDashboardData(message.data); 60 | } 61 | } catch (error) { 62 | console.error('Error parsing WebSocket message:', error); 63 | } 64 | }; 65 | 66 | websocket.onclose = () => { 67 | console.log('WebSocket disconnected, attempting to reconnect...'); 68 | // Don't override serverStatus here - WebSocket is just the transport 69 | setTimeout(setupWebSocket, 5000); 70 | }; 71 | 72 | websocket.onerror = (error) => { 73 | console.error('WebSocket error:', error); 74 | // Don't override serverStatus here 75 | }; 76 | 77 | setWs(websocket); 78 | 79 | return websocket; 80 | }, []); 81 | 82 | // Manual refresh 83 | const refreshData = useCallback(async () => { 84 | setIsLoading(true); 85 | await fetchInitialData(); 86 | }, [fetchInitialData]); 87 | 88 | // Optimistically update a single application locally (immediate UI feedback) 89 | const updateApplication = useCallback((appId, patch) => { 90 | setDashboardData(prev => { 91 | if (!prev || !prev.applications) return prev; 92 | const updatedApps = prev.applications.map(app => app.appId === appId ? { ...app, ...patch } : app); 93 | return { ...prev, applications: updatedApps, lastUpdated: new Date().toISOString() }; 94 | }); 95 | }, []); 96 | 97 | useEffect(() => { 98 | fetchInitialData(); 99 | const websocket = setupWebSocket(); 100 | 101 | return () => { 102 | if (websocket) { 103 | websocket.close(); 104 | } 105 | }; 106 | }, [fetchInitialData, setupWebSocket]); 107 | 108 | return { 109 | dashboardData, 110 | isLoading, 111 | error, 112 | refreshData, 113 | updateApplication 114 | }; 115 | }; 116 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/handler/DashboardHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.server.handler; 2 | 3 | import com.sun.net.httpserver.HttpExchange; 4 | import com.sun.net.httpserver.HttpHandler; 5 | import io.github.brunoborges.jlib.common.JavaApplication; 6 | import io.github.brunoborges.jlib.server.service.ApplicationService; 7 | 8 | import java.io.IOException; 9 | import java.io.OutputStream; 10 | import java.nio.charset.StandardCharsets; 11 | import java.time.Instant; 12 | import org.json.JSONArray; 13 | import org.json.JSONObject; 14 | 15 | /** 16 | * Dashboard handler returning a minimal summary optimized for the UI. 17 | * 18 | * New response shape (no per-jar arrays, no manifest data): 19 | * { 20 | * "applicationCount": , 21 | * "jarCount": , // total jars across all apps 22 | * "activeJarCount": , // loaded=true 23 | * "inactiveJarCount": , // loaded=false 24 | * "applications": [ 25 | * { 26 | * "appId": "...", 27 | * "name": "...", 28 | * "commandLine": "...", 29 | * "lastUpdated": "ISO-8601", 30 | * "activeJarCount": , 31 | * "totalJarCount": 32 | * } 33 | * ], 34 | * "lastUpdated":"ISO-8601", 35 | * "serverStatus":"connected" 36 | * } 37 | */ 38 | public class DashboardHandler implements HttpHandler { 39 | 40 | private final ApplicationService applicationService; 41 | 42 | public DashboardHandler(ApplicationService applicationService) { 43 | this.applicationService = applicationService; 44 | } 45 | 46 | @Override 47 | public void handle(HttpExchange exchange) throws IOException { 48 | if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { 49 | send(exchange, 405, "Method not allowed"); 50 | return; 51 | } 52 | 53 | int totalJars = 0; 54 | int activeJars = 0; 55 | JSONArray apps = new JSONArray(); 56 | for (JavaApplication app : applicationService.getAllApplications()) { 57 | int appTotal = app.jars.size(); 58 | int appActive = 0; 59 | for (var jar : app.jars.values()) { 60 | totalJars++; 61 | if (jar.isLoaded()) { 62 | activeJars++; appActive++; } 63 | } 64 | JSONObject a = new JSONObject(); 65 | a.put("appId", app.appId); 66 | a.put("name", app.name == null ? "" : app.name); 67 | a.put("commandLine", app.commandLine); 68 | a.put("lastUpdated", app.lastUpdated.toString()); 69 | a.put("activeJarCount", appActive); 70 | a.put("totalJarCount", appTotal); 71 | // Include JDK metadata for dashboard quick display 72 | a.put("jdkVersion", app.jdkVersion); 73 | a.put("jdkVendor", app.jdkVendor); 74 | a.put("jdkPath", app.jdkPath); 75 | apps.put(a); 76 | } 77 | int inactiveJars = totalJars - activeJars; 78 | JSONObject root = new JSONObject(); 79 | root.put("applicationCount", apps.length()); 80 | root.put("jarCount", totalJars); 81 | root.put("activeJarCount", activeJars); 82 | root.put("inactiveJarCount", inactiveJars); 83 | root.put("applications", apps); 84 | root.put("lastUpdated", Instant.now().toString()); 85 | root.put("serverStatus", "connected"); 86 | 87 | byte[] bytes = root.toString().getBytes(StandardCharsets.UTF_8); 88 | exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); 89 | exchange.sendResponseHeaders(200, bytes.length); 90 | try (OutputStream os = exchange.getResponseBody()) { 91 | os.write(bytes); 92 | } 93 | } 94 | 95 | private void send(HttpExchange ex, int code, String msg) throws IOException { 96 | byte[] b = msg.getBytes(StandardCharsets.UTF_8); 97 | ex.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8"); 98 | ex.sendResponseHeaders(code, b.length); 99 | try (OutputStream os = ex.getResponseBody()) { 100 | os.write(b); 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /frontend/src/components/HelpDialog.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { initLucideIcons } from '../utils/helpers'; 3 | 4 | /* Basic modal explaining how jarId and application appId are derived. 5 | * jarId definition sourced from JarMetadata.getJarId() JavaDoc. 6 | * appId definition: generated by agent (likely random/UUID) identifying a JVM instance process lifetime. 7 | */ 8 | const HelpDialog = ({ isOpen, onClose }) => { 9 | useEffect(() => { initLucideIcons(); }, [isOpen]); 10 | if (!isOpen) return null; 11 | return ( 12 |
13 |
14 |
15 |

Help & Reference

16 | 17 |
18 |
19 |
20 |

JAR Identifier (jarId)

21 |

Each JAR is assigned a stable jarId used to de-duplicate and correlate across applications.

22 |
    23 |
  1. If checksum known: jarId = SHA-256( sha256Hash + ":" + fileName )
  2. 24 |
  3. Else (checksum pending): jarId = SHA-256( fileName + ":" + size + ":" + fullPath )
  4. 25 |
26 |

When the real checksum becomes available, the jarId may shift from the fallback to the final hash; clients should tolerate a one-time change early in lifecycle.

27 |
28 |
29 |

Application Identifier (appId)

30 |

The appId uniquely identifies a running JVM observed by the agent. It is generated when the application first registers and remains stable for that JVM process lifetime. Metadata like name/description/tags can be edited without changing the appId.

31 |
32 |
33 |

Nested JAR Paths

34 |

Paths containing !/ indicate a nested JAR (e.g. inside a Spring Boot fat JAR). The portion before !/ is the container; after is the inner entry path.

35 |
36 |
37 |

Tips

38 |
    39 |
  • Use the checksum (when present) to cross-check artifacts externally.
  • 40 |
  • The Maven search link derives a heuristic artifact name from the file name.
  • 41 |
  • Hover over relative times to see the precise timestamp (ISO-8601).
  • 42 |
43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 | ); 51 | }; 52 | 53 | export default HelpDialog; -------------------------------------------------------------------------------- /agent/src/main/java/io/github/brunoborges/jlib/agent/JarInventory.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.agent; 2 | 3 | import java.io.File; 4 | import java.io.FileInputStream; 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | import java.io.PrintStream; 8 | import java.security.DigestInputStream; 9 | import java.security.MessageDigest; 10 | import java.security.NoSuchAlgorithmException; 11 | import java.util.ArrayList; 12 | import java.util.Base64; 13 | import java.util.Collection; 14 | import java.util.Map; 15 | import java.util.concurrent.ConcurrentHashMap; 16 | 17 | import io.github.brunoborges.jlib.common.JarMetadata; 18 | 19 | /** 20 | * Central inventory of all JARs (top-level + nested) observed or declared by 21 | * the agent. 22 | * Each record tracks: loaded state, filename, full path/identifier (including 23 | * nested !/ form), 24 | * file size (bytes) and SHA-256 hash (URL-safe base64, or '?' if unavailable). 25 | */ 26 | public final class JarInventory { 27 | 28 | private final Map jars = new ConcurrentHashMap<>(); 29 | 30 | /** 31 | * Register a jar we discovered (declared) with optional size & hash supplier. 32 | */ 33 | public JarMetadata registerDeclared(String id, long size, HashSupplier hashSupplier) { 34 | return jars.computeIfAbsent(id, k -> new JarMetadata(k, simpleName(k), size, computeHash(hashSupplier))); 35 | } 36 | 37 | /** 38 | * Mark a jar (top-level or nested) as having provided at least one loaded 39 | * class. 40 | */ 41 | public void markLoaded(String id) { 42 | if (id == null) 43 | return; 44 | jars.compute(id, (k, existing) -> { 45 | if (existing == null) { 46 | existing = new JarMetadata(k, simpleName(k), -1L, "?"); 47 | } 48 | existing.markLoaded(); 49 | return existing; 50 | }); 51 | } 52 | 53 | public Collection snapshot() { 54 | return new ArrayList<>(jars.values()); 55 | } 56 | 57 | /** Attach manifest attributes to an existing jar if absent. */ 58 | public void attachManifest(String id, Map manifestAttrs) { 59 | if (id == null || manifestAttrs == null || manifestAttrs.isEmpty()) 60 | return; 61 | JarMetadata meta = jars.get(id); 62 | if (meta != null) { 63 | meta.setManifestAttributesIfAbsent(manifestAttrs); 64 | } 65 | } 66 | 67 | private String computeHash(HashSupplier supplier) { 68 | if (supplier == null) { 69 | return "?"; 70 | } 71 | 72 | try { 73 | return supplier.hash(); 74 | } catch (Exception e) { 75 | return "?"; 76 | } 77 | } 78 | 79 | private String simpleName(String id) { 80 | int bang = id.lastIndexOf("!/"); 81 | String tail = bang >= 0 ? id.substring(bang + 2) : id; 82 | int slash = Math.max(tail.lastIndexOf('/'), tail.lastIndexOf('\\')); 83 | return slash >= 0 ? tail.substring(slash + 1) : tail; 84 | } 85 | 86 | /** Print improved human-readable report (sorted + summary). */ 87 | public void report(PrintStream out) { 88 | JarInventoryReport.generateReport(snapshot(), out); 89 | } 90 | 91 | @FunctionalInterface 92 | public interface HashSupplier { 93 | String hash() throws Exception; 94 | } 95 | 96 | public static HashSupplier fileHashSupplier(File file) { 97 | return () -> digest(new FileInputStream(file)); 98 | } 99 | 100 | public static HashSupplier nestedJarHashSupplier(java.util.jar.JarFile outer, java.util.jar.JarEntry entry) { 101 | return () -> digest(outer.getInputStream(entry)); 102 | } 103 | 104 | private static String digest(InputStream in) throws IOException, NoSuchAlgorithmException { 105 | try (InputStream is = in; 106 | DigestInputStream dis = new DigestInputStream(is, MessageDigest.getInstance("SHA-256"))) { 107 | byte[] buf = new byte[8192]; 108 | while (dis.read(buf) != -1) { 109 | /* consume */ } 110 | byte[] d = dis.getMessageDigest().digest(); 111 | return Base64.getUrlEncoder().withoutPadding().encodeToString(d); 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /docs/latest.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Latest Release (1.0.0) 4 | permalink: /latest.html 5 | --- 6 | 7 | # JLib Inspector 1.0.0 8 | 9 | The first stable release of JLib Inspector is now available. 10 | 11 | ## Artifacts 12 | 13 | Download shaded JARs from the GitHub Releases page: 14 | 15 | - Agent: `jlib-inspector-agent-1.0.0-shaded.jar` 16 | - Server: `jlib-inspector-server-1.0.0-shaded.jar` 17 | 18 | GitHub Releases: [v1.0.0](https://github.com/{{ site.repository }}/releases/tag/v1.0.0) 19 | 20 | > Verify SHA256 checksums listed in the release before running in production. 21 | 22 | ## Frontend Container Image 23 | 24 | A pre-built container image for the dashboard (UI + REST proxy + WebSocket) is published. 25 | 26 | macOS / Windows (Docker Desktop) quick start (Linux differs; see below): 27 | 28 | ```bash 29 | docker run \ 30 | -e JLIB_SERVER_URL=http://host.docker.internal:8080 \ 31 | -ti -p 3000:3000 -p 3001:3001 \ 32 | brunoborges/jlib-frontend:1.0.0 33 | ``` 34 | 35 | Then open: 36 | 37 | WebSocket default port: 3001 (exposed). Change via `PORT` / `WS_PORT` if needed. 38 | 39 | ## Running the Server (Java) 40 | 41 | ```bash 42 | java -jar jlib-inspector-server-1.0.0-shaded.jar 8080 43 | ``` 44 | 45 | Health check: 46 | ```bash 47 | curl -s http://localhost:8080/health 48 | ``` 49 | 50 | ## Attaching the Agent to Your App 51 | 52 | ```bash 53 | java -javaagent:/path/to/jlib-inspector-agent-1.0.0-shaded.jar=server:8080 -jar your-app.jar 54 | ``` 55 | 56 | Explicit host & port: 57 | ```bash 58 | java -javaagent:/path/agent.jar=server:my-host.example.com:8080 -jar your-app.jar 59 | ``` 60 | 61 | Environment variable override (takes precedence over agent arg): 62 | ```bash 63 | export JLIB_SERVER_URL="http://my-host.example.com:8080" 64 | java -javaagent:/path/agent.jar -jar your-app.jar 65 | ``` 66 | 67 | ## Docker Networking Notes (Linux vs macOS / Windows) 68 | 69 | Host networking differs across platforms; this affects a containerized frontend reaching a server running on your host. 70 | 71 | ### Linux 72 | 73 | True host networking is available: 74 | ```bash 75 | docker run --rm --network host brunoborges/jlib-frontend:1.0.0 76 | ``` 77 | * Dashboard: 78 | * Frontend reaches server at `http://localhost:8080` 79 | * `-p` mappings unnecessary / ignored with `--network host`. 80 | 81 | ### macOS & Windows (Docker Desktop) 82 | 83 | `--network host` is a partial emulation; container `localhost` != host. 84 | Use the special DNS name: 85 | ```bash 86 | JLIB_SERVER_URL=http://host.docker.internal:8080 87 | ``` 88 | Example: 89 | ```bash 90 | docker run \ 91 | -e JLIB_SERVER_URL=http://host.docker.internal:8080 \ 92 | -p 3000:3000 -p 3001:3001 \ 93 | brunoborges/jlib-frontend:1.0.0 94 | ``` 95 | Why: 96 | * `host.docker.internal` resolves to the host OS 97 | * Port publishing (`-p`) exposes the UI back to the host 98 | 99 | ### Verifying Connectivity 100 | 101 | macOS / Windows container reaching host server: 102 | ```bash 103 | docker run --rm alpine sh -c "apk add --no-cache curl >/dev/null && curl -v http://host.docker.internal:8080/health" 104 | ``` 105 | Linux host-network quick check: 106 | ```bash 107 | docker run --rm --network host alpine curl -s http://localhost:8080/health 108 | ``` 109 | 110 | ## Troubleshooting 111 | 112 | | Symptom | Cause | Fix | 113 | | ------- | ----- | --- | 114 | | Frontend shows no apps | Agent not sending or server unreachable | Verify agent arg / env + server `/health` | 115 | | Connection refused (macOS) | Used `localhost` inside container | Use `host.docker.internal` | 116 | | Slow shutdown w/ server reporting | Agent async send finishing | Accept or lower timeout (future config) | 117 | | Multiple apps overwrite | Same computed App ID | Add distinguishing jars / params | 118 | 119 | ## Security Considerations 120 | 121 | * Run server inside trusted network / behind firewall initially 122 | * Add TLS (reverse proxy) for remote agents 123 | * Inventory payload includes JAR metadata—avoid unencrypted transit on untrusted networks 124 | 125 | ## Roadmap Highlights After 1.0.0 126 | 127 | * Incremental (periodic) reporting 128 | * OpenTelemetry export 129 | * SBOM correlation & drift diff 130 | * Historical retention & comparison 131 | 132 | ## Feedback 133 | 134 | Open issues or discussions: 135 | 136 | Happy inspecting! -------------------------------------------------------------------------------- /common/src/main/java/io/github/brunoborges/jlib/json/JsonResponseBuilder.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.json; 2 | 3 | import io.github.brunoborges.jlib.common.JarMetadata; 4 | import io.github.brunoborges.jlib.common.JavaApplication; 5 | import org.json.JSONArray; 6 | import org.json.JSONObject; 7 | 8 | /** 9 | * Utility for building JSON responses. 10 | */ 11 | public class JsonResponseBuilder { 12 | 13 | // All JSON generation handled directly by org.json. 14 | 15 | /** 16 | * Builds JSON for applications list. 17 | */ 18 | public static String buildAppsListJson(Iterable applications) { 19 | JSONArray appsArray = new JSONArray(); 20 | for (JavaApplication app : applications) { 21 | JSONObject o = new JSONObject(); 22 | o.put("appId", app.appId); 23 | o.put("name", app.name == null ? "" : app.name); 24 | o.put("commandLine", app.commandLine); 25 | o.put("jdkVersion", app.jdkVersion); 26 | o.put("jdkVendor", app.jdkVendor); 27 | o.put("firstSeen", app.firstSeen.toString()); 28 | o.put("lastUpdated", app.lastUpdated.toString()); 29 | o.put("jarCount", app.jars.size()); 30 | appsArray.put(o); 31 | } 32 | return new JSONObject().put("applications", appsArray).toString(); 33 | } 34 | 35 | /** 36 | * Builds JSON for application details. 37 | */ 38 | public static String buildAppDetailsJson(JavaApplication app) { 39 | JSONObject root = new JSONObject(); 40 | root.put("appId", app.appId); 41 | root.put("name", app.name == null ? "" : app.name); 42 | root.put("description", app.description == null ? "" : app.description); 43 | root.put("commandLine", app.commandLine); 44 | root.put("jdkVersion", app.jdkVersion); 45 | root.put("jdkVendor", app.jdkVendor); 46 | root.put("jdkPath", app.jdkPath); 47 | root.put("firstSeen", app.firstSeen.toString()); 48 | root.put("lastUpdated", app.lastUpdated.toString()); 49 | root.put("jarCount", app.jars.size()); 50 | JSONArray tags = new JSONArray(); 51 | for (String tag : app.tags) { 52 | tags.put(tag); 53 | } 54 | root.put("tags", tags); 55 | JSONArray jars = new JSONArray(); 56 | for (JarMetadata jar : app.jars.values()) { 57 | JSONObject jo = new JSONObject(); 58 | jo.put("path", jar.fullPath); 59 | jo.put("fileName", jar.fileName); 60 | jo.put("size", jar.size); 61 | jo.put("checksum", jar.sha256Hash); 62 | jo.put("jarId", jar.getJarId()); 63 | jo.put("loaded", jar.isLoaded()); 64 | jo.put("lastAccessed", jar.getLastAccessed().toString()); 65 | jars.put(jo); 66 | } 67 | root.put("jars", jars); 68 | if (app.jvmDetails != null) { 69 | try { 70 | root.put("jvmDetails", new JSONObject(app.jvmDetails)); 71 | } catch (Exception e) { 72 | // Store raw if parsing fails 73 | root.put("jvmDetails", app.jvmDetails); 74 | } 75 | } 76 | return root.toString(); 77 | } 78 | 79 | // Legacy helper removed (replaced by direct JSONArray building). 80 | 81 | /** 82 | * Builds JSON for JARs list. 83 | */ 84 | public static String buildJarsListJson(JavaApplication app) { 85 | JSONArray jars = new JSONArray(); 86 | for (JarMetadata jar : app.jars.values()) { 87 | JSONObject jo = new JSONObject(); 88 | jo.put("path", jar.fullPath); 89 | jo.put("fileName", jar.fileName); 90 | jo.put("size", jar.size); 91 | jo.put("checksum", jar.sha256Hash); 92 | jo.put("jarId", jar.getJarId()); 93 | jo.put("loaded", jar.isLoaded()); 94 | jo.put("lastAccessed", jar.getLastAccessed().toString()); 95 | jars.put(jo); 96 | } 97 | return new JSONObject().put("jars", jars).toString(); 98 | } 99 | 100 | /** 101 | * Builds JSON for health check. 102 | */ 103 | public static String buildHealthJson(int applicationCount) { 104 | return new JSONObject().put("status", "healthy").put("applications", applicationCount).toString(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /agent/src/main/java/io/github/brunoborges/jlib/agent/jvm/IdentifyGC.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.agent.jvm; 2 | 3 | import java.lang.invoke.MethodHandles; 4 | import java.lang.invoke.MethodType; 5 | import java.lang.management.ManagementFactory; 6 | import java.util.Arrays; 7 | import java.util.TreeMap; 8 | import java.util.logging.Logger; 9 | 10 | public class IdentifyGC { 11 | 12 | public static enum GCType { 13 | G1GC, ConcMarkSweepGC, ParallelGC, SerialGC, ShenandoahGC, ZGC, Unknown; 14 | } 15 | 16 | private static final String HOTSPOT_BEAN_NAME = "com.sun.management:type=HotSpotDiagnostic"; 17 | 18 | private static final Logger LOGGER = Logger.getLogger(IdentifyGC.class.getName()); 19 | 20 | private final GCType identifiedGC; 21 | 22 | private Class vmOptionClazz, hotSpotDiagnosticMXBeanClazz; 23 | 24 | private PrintFlagsFinal flags; 25 | 26 | public IdentifyGC() { 27 | this(null); 28 | } 29 | 30 | public IdentifyGC(PrintFlagsFinal flags) { 31 | try { 32 | vmOptionClazz = Class.forName("com.sun.management.VMOption"); 33 | hotSpotDiagnosticMXBeanClazz = Class.forName("com.sun.management.HotSpotDiagnosticMXBean"); 34 | } catch (ClassNotFoundException e) { 35 | LOGGER.warning("Can't read VMOption nor HotSpotDiagnosticMXBean classes."); 36 | } 37 | 38 | this.flags = flags; 39 | 40 | identifiedGC = identifyGC(); 41 | } 42 | 43 | private GCType identifyGC() { 44 | try { 45 | var flags = Arrays.asList(GCType.values()); 46 | var flagSettings = new TreeMap(); 47 | for (var flag : flags) { 48 | var vmOption = getVMOption("Use" + flag.name()); 49 | if (vmOption != null) { 50 | flagSettings.put(flag, vmOption); 51 | } 52 | } 53 | return flagSettings.entrySet().stream().filter(e -> "true".equals(e.getValue())).map(e -> e.getKey()) 54 | .findFirst().orElse(GCType.Unknown); 55 | } catch (Exception e) { 56 | throw new RuntimeException(e); 57 | } 58 | } 59 | 60 | private String getVMOption(String vmOptionName) { 61 | if (flags != null) { 62 | String vmOption = flags.getVMOption(vmOptionName); 63 | if (vmOption != null) { 64 | return vmOption; 65 | } else { 66 | // if we don't have one GC flag, we don't have any of the GC flags 67 | flags = null; 68 | } 69 | } 70 | 71 | // initialize hotspot diagnostic MBean 72 | initHotspotMBean(); 73 | try { 74 | var publicLookup = MethodHandles.publicLookup(); 75 | var mt = MethodType.methodType(vmOptionClazz, String.class); 76 | var getVMOption = publicLookup.findVirtual(hotSpotDiagnosticMXBeanClazz, "getVMOption", mt); 77 | var vmOption = getVMOption.invokeWithArguments(hotspotMBean, vmOptionName); 78 | 79 | var mt2 = MethodType.methodType(String.class); 80 | var mh2 = publicLookup.findVirtual(vmOptionClazz, "getValue", mt2); 81 | return (String) mh2.invokeWithArguments(vmOption); 82 | } catch (IllegalArgumentException e) { 83 | if (e.getMessage().contains("does not exist")) { 84 | return null; 85 | } 86 | throw e; 87 | } catch (Throwable e) { 88 | throw new RuntimeException(e); 89 | } 90 | } 91 | 92 | private Object hotspotMBean; 93 | 94 | private void initHotspotMBean() { 95 | if (hotspotMBean == null) { 96 | synchronized (IdentifyGC.class) { 97 | if (hotspotMBean == null) { 98 | hotspotMBean = getHotspotMBean(); 99 | } 100 | } 101 | } 102 | } 103 | 104 | private Object getHotspotMBean() { 105 | try { 106 | var server = ManagementFactory.getPlatformMBeanServer(); 107 | return ManagementFactory.newPlatformMXBeanProxy(server, HOTSPOT_BEAN_NAME, hotSpotDiagnosticMXBeanClazz); 108 | } catch (Exception re) { 109 | LOGGER.warning("Can't proxy HotSpotDiagnosticMXBean."); 110 | } 111 | 112 | return null; 113 | } 114 | 115 | public GCType getGCType() { 116 | return identifiedGC; 117 | } 118 | 119 | } -------------------------------------------------------------------------------- /frontend/src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { formatRelativeTime, initLucideIcons } from '../utils/helpers'; 3 | 4 | const Header = ({ 5 | serverStatus, 6 | lastUpdated, 7 | currentView, 8 | onViewToggle, 9 | onOpenServerConfig, 10 | onOpenHelp 11 | }) => { 12 | useEffect(() => { 13 | initLucideIcons(); 14 | }, []); 15 | 16 | const getStatusClass = (status) => { 17 | const classes = { 18 | 'connected': 'bg-green-500', 19 | 'disconnected': 'bg-red-500', 20 | 'unknown': 'bg-gray-400' 21 | }; 22 | return `w-3 h-3 rounded-full shadow-sm ${classes[status]}`; 23 | }; 24 | 25 | const getStatusTextClass = (status) => { 26 | const classes = { 27 | 'connected': 'text-green-600', 28 | 'disconnected': 'text-red-600', 29 | 'unknown': 'text-gray-600' 30 | }; 31 | return `text-sm font-medium ${classes[status]}`; 32 | }; 33 | 34 | const getStatusText = (status) => { 35 | const texts = { 36 | 'connected': 'Connected', 37 | 'disconnected': 'Disconnected', 38 | 'unknown': 'Connecting...' 39 | }; 40 | return texts[status]; 41 | }; 42 | 43 | return ( 44 |
45 |
46 |
47 |
48 |
49 | 50 |
51 |
52 |

JLib Inspector

53 |

Java Applications & JAR Dependencies Monitor

54 |
55 |
56 |
57 |
58 |
59 | 60 | {getStatusText(serverStatus)} 61 | 62 |
63 |
64 | {lastUpdated ? `Updated ${formatRelativeTime(lastUpdated)}` : 'Never updated'} 65 |
66 |
67 | 74 | 81 | 88 |
89 |
90 |
91 |
92 |
93 | ); 94 | }; 95 | 96 | export default Header; 97 | -------------------------------------------------------------------------------- /server/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4.1.0 3 | 4 | 5 | io.github.brunoborges 6 | jlib-inspector 7 | 1.0.1-SNAPSHOT 8 | ../ 9 | 10 | 11 | jlib-inspector-server 12 | jlib-inspector-server 13 | 14 | 15 | 21 16 | 17 | 18 | 19 | 20 | io.github.brunoborges 21 | jlib-inspector-common 22 | 23 | 24 | 25 | org.json 26 | json 27 | 28 | 29 | 30 | org.junit.jupiter 31 | junit-jupiter 32 | test 33 | 34 | 35 | 36 | 37 | 38 | 39 | org.apache.maven.plugins 40 | maven-jar-plugin 41 | 42 | 43 | 44 | io.github.brunoborges.jlib.server.JLibServer 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | org.apache.maven.plugins 53 | maven-shade-plugin 54 | 55 | 56 | package 57 | 58 | shade 59 | 60 | 61 | 62 | false 63 | true 64 | 65 | 66 | io.github.brunoborges.jlib.server.JLibServer 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | org.apache.maven.plugins 77 | maven-surefire-plugin 78 | 79 | 80 | **/*Test.java 81 | **/*Tests.java 82 | 83 | 84 | 85 | 86 | 87 | 88 | org.jacoco 89 | jacoco-maven-plugin 90 | 91 | 92 | 93 | prepare-agent 94 | 95 | 96 | 97 | report 98 | test 99 | 100 | report 101 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /frontend/src/components/JarItem.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { formatFileSize, getMvnRepositoryUrl, copyToClipboard, initLucideIcons } from '../utils/helpers'; 3 | 4 | const JarItem = ({ jar, isCompact = false, isUniqueJar = false, onOpenApp, appNameById, onOpenJar }) => { 5 | const [copyStatus, setCopyStatus] = useState(null); 6 | 7 | useEffect(() => { 8 | initLucideIcons(); 9 | }, [copyStatus]); 10 | 11 | const isJar = jar.fileName.endsWith('.jar'); 12 | const iconClass = !isJar ? 'w-4 h-4 text-blue-500' : 'w-4 h-4 text-green-500'; 13 | const iconName = !isJar ? 'layers' : 'package'; 14 | const mvnUrl = getMvnRepositoryUrl(jar.fileName); 15 | 16 | const handleCopyChecksum = async (e) => { 17 | e.stopPropagation(); 18 | const success = await copyToClipboard(jar.checksum); 19 | setCopyStatus(success ? 'success' : 'error'); 20 | setTimeout(() => setCopyStatus(null), 2000); 21 | }; 22 | 23 | return ( 24 |
{ if (onOpenJar && jar.jarId) onOpenJar(jar.jarId); }}> 25 |
26 |
27 | 28 |
29 |
30 | {jar.fileName || 'Unknown JAR'} 31 |
32 |

{jar.path}

33 | {jar.checksum && jar.checksum !== '?' && ( 34 |
35 |
36 | SHA-256: 37 | {jar.checksum} 38 |
39 | 53 |
54 | )} 55 | {mvnUrl && ( 56 | 62 | 63 | Search on mvnrepository.com 64 | 65 | )} 66 |
67 |
68 |
69 |

{formatFileSize(jar.size)}

70 |

71 | {jar.loaded ? 'Loaded' : 'Not loaded'} 72 |

73 |
74 |
75 |
76 | ); 77 | }; 78 | 79 | export default JarItem; 80 | -------------------------------------------------------------------------------- /agent/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4.1.0 3 | 4 | 5 | io.github.brunoborges 6 | jlib-inspector 7 | 1.0.1-SNAPSHOT 8 | ../ 9 | 10 | 11 | jlib-inspector-agent 12 | jlib-inspector-agent 13 | 14 | 15 | 21 16 | 17 | 18 | 19 | 20 | io.github.brunoborges 21 | jlib-inspector-common 22 | 23 | 24 | 25 | org.json 26 | json 27 | 28 | 29 | 30 | 31 | org.junit.jupiter 32 | junit-jupiter 33 | test 34 | 35 | 36 | 37 | 38 | 39 | 40 | org.apache.maven.plugins 41 | maven-jar-plugin 42 | 43 | 44 | 45 | io.github.brunoborges.jlib.agent.InspectorAgent 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | org.apache.maven.plugins 54 | maven-shade-plugin 55 | 56 | 57 | package 58 | 59 | shade 60 | 61 | 62 | false 63 | true 64 | 65 | 66 | 67 | io.github.brunoborges.jlib.agent.InspectorAgent 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | org.apache.maven.plugins 79 | maven-surefire-plugin 80 | 81 | 82 | **/*Test.java 83 | **/*Tests.java 84 | 85 | 86 | 87 | 88 | 89 | 90 | org.jacoco 91 | jacoco-maven-plugin 92 | 93 | 94 | 95 | prepare-agent 96 | 97 | 98 | 99 | report 100 | test 101 | 102 | report 103 | 104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /frontend/src/components/ApplicationsList.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import ApplicationCard from './ApplicationCard'; 3 | import { initLucideIcons } from '../utils/helpers'; 4 | 5 | const ApplicationsList = ({ 6 | applications, 7 | currentView, 8 | filteredCount, 9 | totalCount, 10 | onOpenJar, 11 | onRefresh, 12 | searchTerm, 13 | onSearchChange, 14 | filterType, 15 | onFilterChange, 16 | isRefreshing 17 | }) => { 18 | useEffect(() => { 19 | initLucideIcons(); 20 | }, []); 21 | 22 | if (applications.length === 0) { 23 | return ( 24 |
25 | 26 |

No Applications Found

27 |

No Java applications are currently being monitored.

28 | 35 |
36 | ); 37 | } 38 | 39 | const containerClass = currentView === 'grid' ? 'grid-view' : 'list-view space-y-4'; 40 | 41 | return ( 42 |
43 |
44 |
45 |
46 |

Java Applications

47 |

Monitored applications and their dependencies

48 |
49 |
50 |
51 |
52 |
53 | 54 |
55 | onSearchChange(e.target.value)} 59 | className="search-input block w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 text-sm" 60 | placeholder="Search applications..." 61 | /> 62 |
63 |
64 | 73 | 81 |
82 | {filteredCount} of {totalCount} apps 83 |
84 |
85 |
86 |
87 | 88 |
89 | {applications.map((app, index) => ( 90 | 96 | ))} 97 |
98 |
99 | ); 100 | }; 101 | 102 | export default ApplicationsList; 103 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: JLib Inspector 4 | --- 5 | 6 |
7 |

Runtime Library Intelligence
for Production JVMs

8 |

Stop guessing which dependencies your services *really* use. JLib Inspector captures a precise, low‑overhead inventory of loaded JARs & classes so you can shrink images, prioritize CVE fixes, and eliminate dependency drift.

9 | 14 |
15 | 16 | ## Why It Matters {#why} 17 | 18 |
19 |

Trim Bloat

Identify JARs never loaded in production to reduce image size and attack surface, and speed up startup.

20 |

Prioritize CVEs

Focus remediation on libraries actually resident in memory, not just declared.

21 |

SBOM Reality Check

Compare runtime inventory with build‑time SBOM to catch drift & shading surprises.

22 |

Audit Evidence

Produce timestamped runtime snapshots for compliance & forensic review.

23 |
24 | 25 | ## Architecture (High Level) {#architecture} 26 | 27 |
[ JVM + Agent ] -- snapshots --> [ Inspector Server ] -- REST --> [ UI / Integrations ] 28 | | | 29 | +-- load events (coalesced) -- + 30 | Hashing (optional) · Version heuristics · Timestamping 31 |
32 | 33 | Key principles: 34 | 35 | - Passive & low overhead: no bytecode weaving required. 36 | - Leverages existing, official APIs in Java SE (Instrumentation). 37 | - Extensible: future exporters (CycloneDX, OpenTelemetry events, etc.). 38 | 39 | ## Data Captured 40 | 41 | | Dimension | Notes | 42 | | ----------------- | -------------------------------------------------------- | 43 | | JARs in Classpath | Shows JARs that may have not been loaded | 44 | | Loaded JARs | List of JARs actually loaded by classloaders | 45 | | JAR Path | Full path on disk (if available) | 46 | | Nested JARs | Supports Spring Boot, One-JAR, etc. | 47 | | JAR Manifest | Extracts content from the file MANIFEST.mf in each JAR. | 48 | | JAR Hash | SHA-256 for integrity / SBOM correlation. | 49 | 50 | ## Quick Start (Docker Desktop) {#quick-start} 51 | 52 | You can get everything (server, frontend, sample app with agent) running with a single command using the provided Compose setup. 53 | 54 |
55 | 1. Clone & Launch 56 |

 57 | git clone https://github.com/{{ site.repository }}.git
 58 | cd jlib-inspector/docker
 59 | ./start-docker.sh
60 | 61 | This builds the Java modules, starts the Inspector server (port 8080), the frontend UI (port 3000), WebSocket (3001), and a launches a sample Spring app instrumented with the agent to push data to the server. 62 | 63 |
64 |
65 | 66 | 2. Explore 67 | 68 |

 69 | # Open the UI
 70 | http://localhost:3000
 71 | 
 72 | # Check server health
 73 | curl -s http://localhost:8080/health
 74 | 
 75 | # List registered apps
 76 | curl -s http://localhost:8080/api/apps | jq
 77 | 
78 | 79 |
80 |
81 | 3. Add Your App 82 |
83 | Run your JVM process pointing the agent at the running server: 84 |
java 
 85 |   -javaagent:/absolute/path/to/agent/ \
 86 |   jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar=server:8080 \
 87 |   -jar your-app.jar
88 | It will appear in the UI within seconds when classes start loading. 89 |
90 | 91 | ### Optional: Rebuild After Code Changes 92 | 93 | Inside the repo root: 94 | 95 | ``` 96 | docker compose down 97 | docker compose up --build 98 | ``` 99 | 100 | ## UI Screenshots 101 | 102 | 112 | 113 | ## Roadmap (Snapshot) 114 | 115 | - OpenTelemetry exporter (spans / events for classload anomalies) 116 | - CycloneDX runtime delta export 117 | - Historical diff view across deploys 118 | - CLI summarizer for CI gating (fail on growth of unused libs) 119 | 120 | ## Contributing 121 | 122 | Issues & PRs welcome. Try to reproduce with the sample app first; include JVM version & environment details for runtime discrepancies. 123 | 124 | --- 125 | 126 |

Toggle theme with the moon icon in the nav. Screenshots open in a lightbox; press ESC or click × to dismiss.

127 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/service/JarService.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.server.service; 2 | 3 | import io.github.brunoborges.jlib.common.JarMetadata; 4 | import io.github.brunoborges.jlib.common.JavaApplication; 5 | // Replaced custom JsonParser usage with org.json for robust parsing. 6 | import org.json.JSONArray; 7 | import org.json.JSONObject; 8 | 9 | import java.time.Instant; 10 | import java.util.logging.Logger; 11 | 12 | /** 13 | * Service for processing JAR updates for applications. 14 | */ 15 | public class JarService { 16 | 17 | private static final Logger LOG = Logger.getLogger(JarService.class.getName()); 18 | 19 | /** 20 | * Processes JAR updates for an application. 21 | */ 22 | public void processJarUpdates(JavaApplication app, String jarsData) { 23 | // Parse JAR data - expects raw JSON array format 24 | // Format: [{"path":"...", "fileName":"...", "size":123, "checksum":"...", 25 | // "loaded":true}, ...] 26 | LOG.info("Processing JAR data: " + jarsData.substring(0, Math.min(200, jarsData.length())) + "..."); 27 | 28 | // Check if we have a proper JSON array 29 | if (!jarsData.trim().startsWith("[") || !jarsData.trim().endsWith("]")) { 30 | LOG.warning("Expected JSON array format for JAR data, got: " 31 | + jarsData.substring(0, Math.min(100, jarsData.length()))); 32 | return; 33 | } 34 | 35 | try { 36 | JSONArray array = new JSONArray(jarsData); 37 | LOG.info("Found " + array.length() + " JAR entries"); 38 | for (int i = 0; i < array.length(); i++) { 39 | JSONObject obj = array.optJSONObject(i); 40 | if (obj == null) continue; 41 | LOG.info("Processing entry " + (i + 1) + ": " + obj.toString().substring(0, Math.min(100, obj.toString().length())) + "..."); 42 | 43 | String path = obj.optString("path", null); 44 | String fileName = obj.optString("fileName", ""); 45 | if (path == null) { 46 | LOG.warning("Skipping JAR entry with null path. Index: " + i); 47 | continue; 48 | } 49 | long size = obj.optLong("size", 0L); 50 | String checksum = obj.has("checksum") ? obj.optString("checksum", null) : null; 51 | boolean loaded = obj.optBoolean("loaded", false); 52 | 53 | JarMetadata jarInfo = app.jars.computeIfAbsent(path, 54 | p -> new JarMetadata(p, fileName, size, checksum, Instant.now(), Instant.now(), loaded)); 55 | 56 | // Manifest extraction: support nested 'manifest' object and flattened 'manifest.' keys 57 | java.util.Map manifestMap = new java.util.LinkedHashMap<>(); 58 | final String manifestPrefix = "manifest."; 59 | for (String key : obj.keySet()) { 60 | if (key.startsWith(manifestPrefix)) { 61 | String realKey = key.substring(manifestPrefix.length()); 62 | if (!realKey.isBlank()) manifestMap.put(realKey, trimQuotes(obj.optString(key))); 63 | } 64 | } 65 | if (obj.has("manifest") && obj.get("manifest") instanceof JSONObject nested) { 66 | for (String k : nested.keySet()) { 67 | String v = nested.optString(k, null); 68 | if (v != null && !k.isBlank()) manifestMap.put(k, v); 69 | } 70 | } 71 | 72 | boolean needsReplacement = (jarInfo.size != size || !java.util.Objects.equals(jarInfo.sha256Hash, checksum)); 73 | if (needsReplacement) { 74 | JarMetadata newJar = new JarMetadata(path, fileName, size, checksum, jarInfo.firstSeen, Instant.now(), loaded); 75 | if (!manifestMap.isEmpty()) { 76 | newJar.setManifestAttributesIfAbsent(manifestMap); 77 | } else if (jarInfo.getManifestAttributes() != null) { 78 | newJar.setManifestAttributesIfAbsent(jarInfo.getManifestAttributes()); 79 | } 80 | app.jars.put(path, newJar); 81 | } else { 82 | if (!manifestMap.isEmpty()) { 83 | jarInfo.setManifestAttributesIfAbsent(manifestMap); 84 | } 85 | if (loaded) { 86 | jarInfo.markLoaded(); 87 | } 88 | } 89 | } 90 | } catch (Exception e) { 91 | LOG.warning("Failed to parse JAR array with org.json: " + e.getMessage()); 92 | } 93 | LOG.info("Processed " + app.jars.size() + " total JARs for application"); 94 | 95 | // Update application's last updated timestamp 96 | app.lastUpdated = Instant.now(); 97 | } 98 | 99 | private static String trimQuotes(String v) { 100 | if (v == null) return null; 101 | String s = v.trim(); 102 | if (s.length() >= 2 && s.startsWith("\"") && s.endsWith("\"")) { 103 | return s.substring(1, s.length()-1); 104 | } 105 | return s; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /frontend/src/components/StatisticsCards.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { initLucideIcons } from '../utils/helpers'; 3 | 4 | const StatisticsCards = ({ applications, onUniqueJarsClick, onTotalAppsClick, counts }) => { 5 | useEffect(() => { 6 | initLucideIcons(); 7 | }, []); 8 | 9 | // Prefer server-provided aggregated counts, fallback to client computation (legacy) 10 | let stats; 11 | if (counts) { 12 | stats = { 13 | totalApps: counts.applicationCount ?? applications.length, 14 | jars: counts.jarCount ?? 0, 15 | activeJars: counts.activeJarCount ?? 0, 16 | inactiveJars: counts.inactiveJarCount ?? 0 17 | }; 18 | } else { 19 | const uniqueJarMap = new Map(); 20 | applications.forEach(app => { 21 | (app.jars || []).forEach(jar => { 22 | const key = jar.fileName || jar.path?.split('/').pop(); 23 | if (!key) return; 24 | const prev = uniqueJarMap.get(key) || { active: false }; 25 | uniqueJarMap.set(key, { active: prev.active || !!jar.loaded }); 26 | }); 27 | }); 28 | const activeCount = Array.from(uniqueJarMap.values()).filter(v => v.active).length; 29 | stats = { 30 | totalApps: applications.length, 31 | jars: uniqueJarMap.size, 32 | activeJars: activeCount, 33 | inactiveJars: uniqueJarMap.size - activeCount, 34 | }; 35 | } 36 | 37 | const cards = [ 38 | { 39 | title: 'Applications', 40 | value: stats.totalApps, 41 | icon: 'server', 42 | gradient: 'from-blue-50 to-blue-100', 43 | iconBg: 'bg-blue-500', 44 | textColor: 'text-blue-700', 45 | valueColor: 'text-blue-900', 46 | clickable: !!onTotalAppsClick, 47 | filter: 'dashboard' 48 | }, 49 | { 50 | title: 'JARs', 51 | value: stats.jars, 52 | icon: 'layers', 53 | gradient: 'from-purple-50 to-purple-100', 54 | iconBg: 'bg-purple-500', 55 | textColor: 'text-purple-700', 56 | valueColor: 'text-purple-900', 57 | clickable: true, 58 | filter: 'all' 59 | }, 60 | { 61 | title: 'Active JARs', 62 | value: stats.activeJars, 63 | icon: 'check-circle', 64 | gradient: 'from-green-50 to-green-100', 65 | iconBg: 'bg-green-500', 66 | textColor: 'text-green-700', 67 | valueColor: 'text-green-900', 68 | clickable: true, 69 | filter: 'active' 70 | }, 71 | { 72 | title: 'Inactive JARs', 73 | value: stats.inactiveJars, 74 | icon: 'slash', 75 | gradient: 'from-gray-50 to-gray-100', 76 | iconBg: 'bg-gray-500', 77 | textColor: 'text-gray-700', 78 | valueColor: 'text-gray-900', 79 | clickable: true, 80 | filter: 'inactive' 81 | } 82 | ]; 83 | 84 | return ( 85 |
86 | {cards.map((card, index) => ( 87 |
(card.title === 'Applications' && onTotalAppsClick ? onTotalAppsClick() : onUniqueJarsClick(card.filter)) : undefined} 95 | title={card.clickable ? (card.title === 'Applications' ? 'Go to dashboard' : 'Click to view detailed list of JARs') : undefined} 96 | > 97 |
98 |
99 |
100 |

{card.title}

101 | {card.clickable && ( 102 | 103 | )} 104 |
105 |

{card.value}

106 | {card.clickable && ( 107 |

Click to explore →

108 | )} 109 |
110 |
113 | 114 |
115 |
116 |
117 | ))} 118 |
119 | ); 120 | }; 121 | 122 | export default StatisticsCards; 123 | -------------------------------------------------------------------------------- /frontend/src/components/ApplicationCard.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | // Removed JarItem import because Recent JARs list is no longer shown on dashboard 3 | import { formatRelativeTime, copyToClipboard, initLucideIcons } from '../utils/helpers'; 4 | 5 | const ApplicationCard = ({ application, isGridView, onOpenJar }) => { 6 | const [copyStatus, setCopyStatus] = useState(null); 7 | const [appIdCopyStatus, setAppIdCopyStatus] = useState(null); 8 | 9 | useEffect(() => { 10 | initLucideIcons(); 11 | }, [copyStatus, appIdCopyStatus]); 12 | 13 | const handleCopyCommand = async (e) => { 14 | e.stopPropagation(); 15 | const success = await copyToClipboard(application.commandLine); 16 | setCopyStatus(success ? 'success' : 'error'); 17 | setTimeout(() => setCopyStatus(null), 2000); 18 | }; 19 | 20 | const handleCopyAppId = async (e) => { 21 | e.stopPropagation(); 22 | const success = await copyToClipboard(application.appId); 23 | setAppIdCopyStatus(success ? 'success' : 'error'); 24 | setTimeout(() => setAppIdCopyStatus(null), 2000); 25 | }; 26 | 27 | const displayName = (application.name && application.name.trim().length > 0) 28 | ? application.name.trim() 29 | : 'Application'; 30 | 31 | return ( 32 |
onOpenJar(application)} 35 | > 36 |
37 |
38 |
39 | 40 |
41 |
42 |

{displayName}

43 |
44 |

{application.appId.substring(0, 12)}...

45 | 59 |
60 |
61 |
62 |
63 |
64 | {application.activeJarCount} 65 | / 66 | {application.totalJarCount} 67 | JARs 68 |
69 |

JDK {application.jdkVersion}

70 |
71 |
72 | 73 |
74 |
75 |

Command Line

76 | 91 |
92 |
93 |

94 | {application.commandLine} 95 |

96 |
97 |
98 | 99 | {/* Recent JARs section removed per data minimization & lazy loading strategy */} 100 | 101 |
102 | Updated {formatRelativeTime(application.lastUpdated)} 103 | Click to view all JARs → 104 |
105 |
106 | ); 107 | }; 108 | 109 | export default ApplicationCard; 110 | -------------------------------------------------------------------------------- /agent/src/main/java/io/github/brunoborges/jlib/agent/jvm/PrintFlagsFinal.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.agent.jvm; 2 | 3 | import java.lang.management.ManagementFactory; 4 | import java.lang.reflect.Method; 5 | import java.util.ArrayList; 6 | import java.util.Collections; 7 | import java.util.List; 8 | import java.util.Map; 9 | import java.util.TreeMap; 10 | import java.util.logging.Level; 11 | import java.util.logging.Logger; 12 | 13 | import com.sun.management.HotSpotDiagnosticMXBean; 14 | import com.sun.management.VMOption; 15 | 16 | public class PrintFlagsFinal { 17 | 18 | private final static Logger LOGGER = Logger.getLogger(PrintFlagsFinal.class.getName()); 19 | 20 | private boolean fallbackToHotSpotDiagnosticMXBean = false; 21 | 22 | private Method getAllFlagsMethod = null; 23 | 24 | private HotSpotDiagnosticMXBean hotspotDiagBean; 25 | 26 | private List jvmFlags; 27 | 28 | public PrintFlagsFinal() { 29 | hotspotDiagBean = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class); 30 | try { 31 | final Class flagClass = Class.forName("com.sun.management.internal.Flag"); 32 | getAllFlagsMethod = flagClass.getDeclaredMethod("getAllFlags"); 33 | getAllFlagsMethod.setAccessible(true); 34 | } catch (Exception e) { 35 | Class inaccessibleException = null; 36 | try { 37 | inaccessibleException = Class.forName("java.lang.reflect.InaccessibleObjectException"); 38 | } catch (ClassNotFoundException e1) { 39 | } 40 | 41 | if (inaccessibleException != null && e.getClass().equals(inaccessibleException)) { 42 | LOGGER.log(Level.SEVERE, "This JVM does not open package jdk.management/com.sun.management.internal to this module. To include all flags, run with --add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED"); 43 | } 44 | 45 | fallbackToHotSpotDiagnosticMXBean = true; 46 | } 47 | 48 | readJVMFlags(); 49 | } 50 | 51 | public List getJVMFlags() { 52 | return jvmFlags; 53 | } 54 | 55 | protected void readJVMFlags() { 56 | // Stop if this has already been done 57 | if (jvmFlags == Collections.EMPTY_LIST || jvmFlags != null) 58 | return; 59 | 60 | List options = Collections.emptyList(); 61 | 62 | if (fallbackToHotSpotDiagnosticMXBean && hotspotDiagBean == null) { 63 | LOGGER.log(Level.SEVERE, "This JVM does not support HotSpotDiagnosticMXBean. Cannot get any flags."); 64 | } else if (fallbackToHotSpotDiagnosticMXBean) { 65 | // only includes writable external flags 66 | options = hotspotDiagBean.getDiagnosticOptions(); 67 | } else { 68 | options = getAllFlagsFromInternal(); 69 | } 70 | 71 | Map optionMap = new TreeMap<>(); 72 | for (final VMOption option : options) { 73 | optionMap.put(option.getName(), option); 74 | } 75 | 76 | List flagsFound = new ArrayList<>(optionMap.size()); 77 | 78 | for (VMOption option : optionMap.values()) { 79 | LOGGER.info(option.getName() + " = " + option.getValue() + " (" + option.getOrigin() + ", " 80 | + (option.isWriteable() ? "read-write" : "read-only") + ")"); 81 | 82 | var jvmFlag = new JVMFlag(option.getName(), option.getValue(), option.getOrigin().name(), 83 | option.isWriteable()); 84 | flagsFound.add(jvmFlag); 85 | } 86 | LOGGER.info(options.size() + " options found"); 87 | jvmFlags = flagsFound; 88 | } 89 | 90 | private List getAllFlagsFromInternal() { 91 | List options = Collections.emptyList(); 92 | try { 93 | final Class flagClass = Class.forName("com.sun.management.internal.Flag"); 94 | final Method getAllFlagsMethod = flagClass.getDeclaredMethod("getAllFlags"); 95 | final Method getVMOptionMethod = flagClass.getDeclaredMethod("getVMOption"); 96 | getAllFlagsMethod.setAccessible(true); 97 | getVMOptionMethod.setAccessible(true); 98 | final Object result = getAllFlagsMethod.invoke(null); 99 | final List flags = (List) result; 100 | options = new ArrayList(flags.size()); 101 | for (final Object flag : flags) { 102 | options.add((VMOption) getVMOptionMethod.invoke(flag)); 103 | } 104 | } catch (Exception e) { 105 | 106 | Class inaccessibleException = null; 107 | try { 108 | inaccessibleException = Class.forName("java.lang.reflect.InaccessibleObjectException"); 109 | } catch (ClassNotFoundException e1) { 110 | } 111 | 112 | if (inaccessibleException != null && e.getClass().equals(inaccessibleException)) { 113 | LOGGER.log(Level.SEVERE, "This JVM does not open package jdk.management/com.sun.management.internal to this module. To include all flags, run with --add-opens jdk.management/com.sun.management.internal=ALL-UNNAMED"); 114 | } 115 | } 116 | return options; 117 | } 118 | 119 | public static class JVMFlag { 120 | private String name; 121 | private String value; 122 | private String origin; 123 | private boolean writable; 124 | 125 | private final String _toString; 126 | 127 | public JVMFlag(String name, String value, String origin, boolean writable) { 128 | this.name = name; 129 | this.value = value; 130 | this.origin = origin; 131 | this.writable = writable; 132 | 133 | _toString = String.format("%s = %s (%s, %s)", name, value, origin, writable ? "read-write" : "read-only"); 134 | } 135 | 136 | public String getName() { 137 | return name; 138 | } 139 | 140 | public String getValue() { 141 | return value; 142 | } 143 | 144 | public String getOrigin() { 145 | return origin; 146 | } 147 | 148 | public boolean isWritable() { 149 | return writable; 150 | } 151 | 152 | @Override 153 | public String toString() { 154 | return _toString; 155 | } 156 | } 157 | 158 | public String getVMOption(String vmOptionName) { 159 | return jvmFlags.stream().filter(flag -> flag.getName().equals(vmOptionName)).findFirst().map(JVMFlag::getValue) 160 | .orElse(null); 161 | } 162 | 163 | } -------------------------------------------------------------------------------- /docs/assets/css/style.css: -------------------------------------------------------------------------------- 1 | /* Dark theme palette */ 2 | :root { --c-bg:#0c1116; --c-bg-alt:#131b25; --c-panel:#162231; --c-accent:#ff7a18; --c-accent-alt:#ffb347; --c-fg:#e6edf3; --c-fg-dim:#9fb3c8; --radius:14px; } 3 | /* Global scroll offset for in-page anchors (adjust if header height changes) */ 4 | html { scroll-padding-top:80px; } 5 | h1[id],h2[id],h3[id],h4[id],h5[id],h6[id] { scroll-margin-top:80px; } 6 | body { margin:0; font-family:'Inter',system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif; background:var(--c-bg); color:var(--c-fg); -webkit-font-smoothing:antialiased; } 7 | a { color:#63b3ff; text-decoration:none; } 8 | a:hover { color:#fff; } 9 | .wrap { width:100%; max-width:1180px; margin:0 auto; padding:0 1.4rem; } 10 | .site-header { position:sticky; top:0; backdrop-filter: blur(12px); background:rgba(12,17,22,.85); border-bottom:1px solid #1d2a36; z-index:40; } 11 | .nav-bar { display:flex; align-items:center; justify-content:space-between; min-height:60px; } 12 | .logo { font-weight:700; font-size:1.05rem; letter-spacing:.5px; background:linear-gradient(120deg,var(--c-accent),var(--c-accent-alt)); -webkit-background-clip:text; background-clip:text; color:transparent; } 13 | .primary-nav a { margin-left:1.1rem; font-size:.9rem; color:var(--c-fg-dim); } 14 | .primary-nav a:hover { color:var(--c-fg); } 15 | #themeToggle { margin-left:1.1rem; background:var(--c-panel); color:var(--c-fg-dim); border:1px solid #223344; padding:.45rem .65rem; border-radius:8px; cursor:pointer; font-size:.85rem; } 16 | #themeToggle:hover { color:var(--c-fg); border-color:#31475c; } 17 | 18 | /* Hero */ 19 | .hero { margin-top:2.5rem; padding:3.2rem 2rem 2.7rem; background:radial-gradient(circle at 20% 20%,#1b2733,#0c1116 70%); border:1px solid #1c2833; border-radius:var(--radius); position:relative; overflow:hidden; } 20 | .hero:before,.hero:after { content:""; position:absolute; width:520px; height:520px; border-radius:50%; filter:blur(90px); opacity:.35; mix-blend-mode:screen; pointer-events:none; } 21 | .hero:before { background:#ff7a18; top:-160px; left:-160px; } 22 | .hero:after { background:#5c6fff; bottom:-200px; right:-140px; } 23 | .hero h1 { margin:0 0 1rem; font-size: clamp(2.3rem,5vw,3.2rem); line-height:1.05; } 24 | .hero p.tagline { font-size:1.15rem; max-width:880px; line-height:1.35; } 25 | .cta-row { margin-top:1.6rem; display:flex; flex-wrap:wrap; gap:.85rem; } 26 | .btn { --_bg:var(--c-panel); --_border:#253648; display:inline-block; padding:.85rem 1.15rem; border-radius:10px; border:1px solid var(--_border); background:var(--_bg); color:var(--c-fg-dim); font-weight:500; font-size:.9rem; letter-spacing:.2px; transition:.2s; } 27 | .btn:hover { color:var(--c-fg); border-color:#345068; } 28 | .btn.accent { background:linear-gradient(110deg,var(--c-accent),var(--c-accent-alt)); color:#111; border:none; font-weight:600; } 29 | .btn.accent:hover { filter:brightness(1.05); } 30 | 31 | /* Feature grid */ 32 | .feature-grid { display:grid; gap:1.35rem; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); margin:2.4rem 0 0; } 33 | .feature { background:var(--c-panel); padding:1.05rem 1rem 1.25rem; border:1px solid #1f2d3a; border-radius: var(--radius); position:relative; overflow:hidden; } 34 | .feature h3 { margin:.2rem 0 .55rem; font-size:1.05rem; letter-spacing:.5px; } 35 | .feature p { margin:0; font-size:.85rem; line-height:1.35; color:var(--c-fg-dim); } 36 | .feature:before { content:""; position:absolute; inset:0; background:linear-gradient(140deg,rgba(255,122,24,.08),rgba(92,111,255,.08)); opacity:0; transition:.3s; } 37 | .feature:hover:before { opacity:1; } 38 | 39 | /* Code & panels */ 40 | pre { background:#111a22; color:#e9eef2; padding:.9rem 1rem; border:1px solid #1f2d3a; border-radius:12px; font-size:.8rem; overflow:auto; line-height:1.35; } 41 | code { font-family:Menlo,monospace; background:#17222c; padding:.2rem .45rem; border-radius:6px; font-size:.8rem; } 42 | 43 | .diagram { background:var(--c-panel); border:1px solid #1f2d3a; padding:1rem 1.2rem; border-radius:12px; font-family:Menlo,monospace; font-size:.75rem; line-height:1.25; margin-top:1.25rem; white-space:pre; overflow:auto; } 44 | 45 | table { width:100%; border-collapse:collapse; margin-top:.5rem; } 46 | th,td { padding:.55rem .65rem; border-bottom:1px solid #1d2a36; font-size:.75rem; } 47 | th { text-align:left; text-transform:uppercase; letter-spacing:.5px; font-weight:600; color:var(--c-fg-dim); } 48 | 49 | .two-col { display:grid; gap:1.6rem; grid-template-columns:repeat(auto-fit,minmax(340px,1fr)); margin-top:1.6rem; } 50 | .gallery { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); margin:2rem 0 1rem; } 51 | .gallery figure { margin:0; background:var(--c-panel); border:1px solid #1f2d3a; border-radius:12px; padding:.6rem .6rem .9rem; position:relative; overflow:hidden; } 52 | .gallery img { width:100%; height:160px; object-fit:cover; border-radius:8px; cursor:pointer; transition:.25s; filter:saturate(.9); } 53 | .gallery img:hover { transform:scale(1.03); filter:saturate(1.05); } 54 | .gallery figcaption { margin:.55rem 0 0; font-size:.7rem; letter-spacing:.5px; text-transform:uppercase; color:var(--c-fg-dim); } 55 | .lightbox { position:fixed; inset:0; background:rgba(0,0,0,.82); display:flex; flex-direction:column; align-items:center; justify-content:center; padding:2rem 1rem 2.5rem; backdrop-filter:blur(4px); z-index:120; } 56 | .lightbox img { max-width: min(94vw,1200px); max-height:70vh; border:1px solid #222e3a; border-radius:14px; box-shadow:0 10px 40px -8px rgba(0,0,0,.65); } 57 | .lightbox .close { position:absolute; top:1.2rem; right:1.4rem; background:#18222d; color:#fff; border:1px solid #2a3947; width:44px; height:44px; font-size:1.4rem; line-height:1; border-radius:50%; cursor:pointer; } 58 | .lightbox .close:hover { background:#223344; } 59 | .lightbox .caption { margin-top:1rem; font-size:.85rem; color:#b9c8d6; max-width: min(92vw,1100px); text-align:center; } 60 | 61 | .site-footer { margin-top:4rem; padding:2.2rem 0 3rem; background:#0a0f13; border-top:1px solid #19242e; } 62 | .site-footer p { margin:.35rem 0; } 63 | .small { font-size:.75rem; color:var(--c-fg-dim); } 64 | 65 | /* Light mode */ 66 | body.theme-light { --c-bg:#ffffff; --c-bg-alt:#f5f7f9; --c-panel:#ffffff; --c-fg:#222; --c-fg-dim:#556270; } 67 | body.theme-light .site-header { background:rgba(255,255,255,.85); border-color:#e1e6eb; } 68 | body.theme-light .feature { border-color:#e3e8ee; } 69 | body.theme-light pre { background:#f3f6f9; border-color:#e3e8ee; color:#222; } 70 | body.theme-light code { background:#ebf0f4; } 71 | body.theme-light .diagram { background:#f3f6f9; border-color:#d9e1e8; } 72 | body.theme-light .site-footer { background:#f5f7f9; border-color:#e1e6eb; } 73 | body.theme-light .gallery figure { border-color:#dfe6ec; background:#ffffff; } 74 | body.theme-light .gallery figcaption { color:#5b6774; } 75 | 76 | @media (max-width:780px){ .hero { padding:2.4rem 1.3rem 2.2rem; } } 77 | -------------------------------------------------------------------------------- /agent/src/main/java/io/github/brunoborges/jlib/agent/JarInventoryReport.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.agent; 2 | 3 | import java.io.PrintStream; 4 | import java.util.ArrayList; 5 | import java.util.Collection; 6 | import java.util.List; 7 | 8 | import io.github.brunoborges.jlib.common.JarMetadata; 9 | 10 | /** 11 | * Handles reporting and formatting of JAR inventory data. 12 | * 13 | *

14 | * This class is responsible for generating human-readable reports from JAR 15 | * inventory data, 16 | * including summary statistics, detailed tables, and various formatting 17 | * utilities. 18 | */ 19 | class JarInventoryReport { 20 | 21 | /** 22 | * Generates a comprehensive human-readable report of JAR inventory data. 23 | * 24 | * @param jarData Collection of JAR metadata to report on 25 | * @param out PrintStream to write the report to 26 | */ 27 | public static void generateReport(Collection jarData, PrintStream out) { 28 | var list = new ArrayList<>(jarData); 29 | 30 | // Sort: loaded first, then top-level before nested, then filename 31 | list.sort((a, b) -> { 32 | int cmpLoaded = Boolean.compare(b.isLoaded(), a.isLoaded()); 33 | if (cmpLoaded != 0) 34 | return cmpLoaded; 35 | int cmpNest = Boolean.compare(a.isTopLevel(), b.isTopLevel()); // top-level (true) should come first 36 | if (cmpNest != 0) 37 | return -cmpNest; // invert because true > false 38 | return a.fileName.compareToIgnoreCase(b.fileName); 39 | }); 40 | 41 | printSummary(list, out); 42 | printDetailedTable(list, out); 43 | } 44 | 45 | /** 46 | * Prints summary statistics about the JAR inventory. 47 | */ 48 | private static void printSummary(List list, PrintStream out) { 49 | int total = list.size(); 50 | long loaded = list.stream().filter(JarMetadata::isLoaded).count(); 51 | long topLevel = list.stream().filter(r -> r.isTopLevel()).count(); 52 | long topLevelLoaded = list.stream().filter(r -> r.isTopLevel() && r.isLoaded()).count(); 53 | long nested = total - topLevel; 54 | long nestedLoaded = loaded - topLevelLoaded; 55 | long totalBytes = list.stream().filter(r -> r.size >= 0).mapToLong(r -> r.size).sum(); 56 | long loadedBytes = list.stream().filter(r -> r.size >= 0 && r.isLoaded()).mapToLong(r -> r.size).sum(); 57 | 58 | out.println("Summary"); 59 | out.println(repeat('-', 72)); 60 | out.printf("Total JARs : %d%n", total); 61 | out.printf("Loaded JARs : %d (%.1f%%) %n", loaded, percentage(loaded, total)); 62 | out.printf("Top-level JARs : %d (loaded %d, %.1f%%) %n", topLevel, topLevelLoaded, 63 | percentage(topLevelLoaded, topLevel)); 64 | out.printf("Nested JARs : %d (loaded %d, %.1f%%) %n", nested, nestedLoaded, 65 | percentage(nestedLoaded, nested == 0 ? 1 : nested)); 66 | if (totalBytes > 0) { 67 | out.printf("Total Size : %s (%d bytes)%n", humanReadableSize(totalBytes), totalBytes); 68 | out.printf("Loaded Size : %s (%d bytes, %.1f%%) %n", humanReadableSize(loadedBytes), loadedBytes, 69 | percentage(loadedBytes, totalBytes)); 70 | } 71 | out.println(); 72 | } 73 | 74 | /** 75 | * Prints a detailed table of all JAR entries. 76 | */ 77 | private static void printDetailedTable(List list, PrintStream out) { 78 | // Table header 79 | String header = String.format("%s %s %s %8s %12s %s %s %s", 80 | pad("#", 3), "L", "T", "SIZE", "BYTES", pad("SHA256(12)", 12), pad("FILENAME", 40), "FULL-PATH / ID"); 81 | 82 | out.println("Details"); 83 | out.println(repeat('-', header.length())); 84 | out.println(header); 85 | out.println(repeat('-', header.length())); 86 | 87 | int index = 1; 88 | for (JarMetadata r : list) { 89 | String idx = pad(String.valueOf(index++), 3); 90 | String l = r.isLoaded() ? "Y" : "-"; 91 | String t = r.isTopLevel() ? "T" : "N"; // top-level or nested 92 | String sizeHuman = r.size >= 0 ? humanReadableSize(r.size) : "?"; 93 | String sizeBytes = r.size >= 0 ? String.valueOf(r.size) : "?"; 94 | String hash = pad(truncateString(r.sha256Hash, 12), 12); 95 | String name = pad(truncateString(r.fileName, 40), 40); 96 | out.printf("%s %s %s %8s %12s %s %s %s%n", idx, l, t, sizeHuman, sizeBytes, hash, name, r.fullPath); 97 | } 98 | 99 | out.println(repeat('-', header.length())); 100 | out.printf( 101 | "Legend: L=Loaded, T=Top-level, N=Nested. Size is human-readable (base 1024). Hash truncated to 12 chars.%n"); 102 | } 103 | 104 | /** 105 | * Truncates a string to the specified maximum length, adding "..." if 106 | * truncated. 107 | */ 108 | private static String truncateString(String s, int max) { 109 | if (s == null) 110 | return "?"; 111 | if (s.length() <= max) 112 | return s; 113 | if (max <= 3) 114 | return s.substring(0, max); 115 | return s.substring(0, max - 3) + "..."; 116 | } 117 | 118 | /** 119 | * Calculates percentage as a double. 120 | */ 121 | private static double percentage(long part, long total) { 122 | if (total <= 0) 123 | return 0.0; 124 | return (part * 100.0) / total; 125 | } 126 | 127 | /** 128 | * Creates a string by repeating a character n times. 129 | */ 130 | private static String repeat(char c, int n) { 131 | return String.valueOf(c).repeat(Math.max(0, n)); 132 | } 133 | 134 | /** 135 | * Pads a string to the specified width with spaces. 136 | */ 137 | private static String pad(String s, int width) { 138 | if (s == null) 139 | s = ""; 140 | return s.length() >= width ? s : s + repeat(' ', width - s.length()); 141 | } 142 | 143 | /** 144 | * Converts bytes to human-readable format (e.g., "1.5KB", "2.3MB"). 145 | */ 146 | private static String humanReadableSize(long bytes) { 147 | if (bytes < 1024) 148 | return bytes + "B"; 149 | 150 | double value = bytes; 151 | String[] units = { "KB", "MB", "GB", "TB", "PB" }; 152 | int unitIndex = -1; 153 | 154 | while (value >= 1024 && unitIndex < units.length - 1) { 155 | value /= 1024.0; 156 | unitIndex++; 157 | if (value < 1024 || unitIndex == units.length - 1) 158 | break; 159 | } 160 | 161 | if (unitIndex < 0) 162 | unitIndex = 0; // safety fallback 163 | return String.format("%.1f%s", value, units[unitIndex]); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /common/src/test/java/io/github/brunoborges/jlib/common/JavaApplicationTest.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.common; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.DisplayName; 5 | import static org.junit.jupiter.api.Assertions.*; 6 | 7 | import java.time.Instant; 8 | 9 | /** 10 | * Unit tests for JavaApplication. 11 | */ 12 | @DisplayName("JavaApplication Tests") 13 | class JavaApplicationTest { 14 | 15 | @Test 16 | @DisplayName("Should create Java application with all required fields") 17 | void shouldCreateJavaApplicationWithAllRequiredFields() { 18 | String appId = "test-app-123"; 19 | String commandLine = "java -jar myapp.jar"; 20 | String jdkVersion = "17.0.2"; 21 | String jdkVendor = "Eclipse Adoptium"; 22 | String jdkPath = "/usr/lib/jvm/java-17"; 23 | 24 | JavaApplication app = new JavaApplication(appId, commandLine, jdkVersion, jdkVendor, jdkPath); 25 | 26 | assertEquals(appId, app.appId); 27 | assertEquals(commandLine, app.commandLine); 28 | assertEquals(jdkVersion, app.jdkVersion); 29 | assertEquals(jdkVendor, app.jdkVendor); 30 | assertEquals(jdkPath, app.jdkPath); 31 | assertNotNull(app.firstSeen); 32 | assertNotNull(app.lastUpdated); 33 | assertTrue(app.jars.isEmpty()); 34 | } 35 | 36 | @Test 37 | @DisplayName("Should initialize timestamps correctly") 38 | void shouldInitializeTimestampsCorrectly() { 39 | JavaApplication app = new JavaApplication("test", "java -jar test.jar", "17", "OpenJDK", "/java"); 40 | 41 | assertTrue(app.firstSeen.compareTo(app.lastUpdated) <= 0); 42 | assertTrue(app.firstSeen.compareTo(Instant.now()) <= 0); 43 | assertTrue(app.lastUpdated.compareTo(Instant.now()) <= 0); 44 | } 45 | 46 | @Test 47 | @DisplayName("Should allow updating last updated timestamp") 48 | void shouldAllowUpdatingLastUpdatedTimestamp() throws InterruptedException { 49 | JavaApplication app = new JavaApplication("test", "java -jar test.jar", "17", "OpenJDK", "/java"); 50 | Instant originalLastUpdated = app.lastUpdated; 51 | 52 | Thread.sleep(10); 53 | app.lastUpdated = Instant.now(); 54 | 55 | assertTrue(app.lastUpdated.isAfter(originalLastUpdated)); 56 | assertEquals(app.firstSeen, app.firstSeen); // Should not change 57 | } 58 | 59 | @Test 60 | @DisplayName("Should provide thread-safe JAR map") 61 | void shouldProvideThreadSafeJarMap() { 62 | JavaApplication app = new JavaApplication("test", "java -jar test.jar", "17", "OpenJDK", "/java"); 63 | 64 | // Add some JARs 65 | JarMetadata jar1 = new JarMetadata("/path/to/jar1.jar", "jar1.jar", 1000L, "hash1"); 66 | JarMetadata jar2 = new JarMetadata("/path/to/jar2.jar", "jar2.jar", 2000L, "hash2"); 67 | 68 | app.jars.put(jar1.fullPath, jar1); 69 | app.jars.put(jar2.fullPath, jar2); 70 | 71 | assertEquals(2, app.jars.size()); 72 | assertEquals(jar1, app.jars.get("/path/to/jar1.jar")); 73 | assertEquals(jar2, app.jars.get("/path/to/jar2.jar")); 74 | } 75 | 76 | @Test 77 | @DisplayName("Should handle null values appropriately") 78 | void shouldHandleNullValuesAppropriately() { 79 | // These should not throw exceptions 80 | JavaApplication app = new JavaApplication(null, null, null, null, null); 81 | 82 | assertNull(app.appId); 83 | assertNull(app.commandLine); 84 | assertNull(app.jdkVersion); 85 | assertNull(app.jdkVendor); 86 | assertNull(app.jdkPath); 87 | assertNotNull(app.firstSeen); 88 | assertNotNull(app.lastUpdated); 89 | assertNotNull(app.jars); 90 | } 91 | 92 | @Test 93 | @DisplayName("Should handle empty strings") 94 | void shouldHandleEmptyStrings() { 95 | JavaApplication app = new JavaApplication("", "", "", "", ""); 96 | 97 | assertTrue(app.appId.isEmpty()); 98 | assertTrue(app.commandLine.isEmpty()); 99 | assertTrue(app.jdkVersion.isEmpty()); 100 | assertTrue(app.jdkVendor.isEmpty()); 101 | assertTrue(app.jdkPath.isEmpty()); 102 | } 103 | 104 | @Test 105 | @DisplayName("Should support complex command lines") 106 | void shouldSupportComplexCommandLines() { 107 | String complexCommandLine = "java -Xms512m -Xmx2g -Dprop=value -javaagent:agent.jar=options -jar myapp.jar --spring.profiles.active=prod"; 108 | 109 | JavaApplication app = new JavaApplication("complex-app", complexCommandLine, "17", "OpenJDK", "/java"); 110 | 111 | assertEquals(complexCommandLine, app.commandLine); 112 | } 113 | 114 | @Test 115 | @DisplayName("Should handle JAR updates correctly") 116 | void shouldHandleJarUpdatesCorrectly() { 117 | JavaApplication app = new JavaApplication("test", "java -jar test.jar", "17", "OpenJDK", "/java"); 118 | 119 | // Add initial JAR 120 | JarMetadata jar = new JarMetadata("/path/to/test.jar", "test.jar", 1000L, "hash1"); 121 | app.jars.put(jar.fullPath, jar); 122 | 123 | assertEquals(1, app.jars.size()); 124 | assertFalse(jar.isLoaded()); 125 | 126 | // Update JAR (mark as loaded) 127 | jar.markLoaded(); 128 | assertTrue(jar.isLoaded()); 129 | 130 | // Replace with new version 131 | JarMetadata updatedJar = new JarMetadata("/path/to/test.jar", "test.jar", 1100L, "hash2"); 132 | app.jars.put(updatedJar.fullPath, updatedJar); 133 | 134 | assertEquals(1, app.jars.size()); 135 | assertEquals(updatedJar, app.jars.get("/path/to/test.jar")); 136 | assertEquals(1100L, app.jars.get("/path/to/test.jar").size); 137 | } 138 | 139 | @Test 140 | @DisplayName("Should handle nested JARs in application") 141 | void shouldHandleNestedJarsInApplication() { 142 | JavaApplication app = new JavaApplication("spring-app", "java -jar spring-boot-app.jar", "17", "OpenJDK", "/java"); 143 | 144 | // Add main JAR 145 | JarMetadata mainJar = new JarMetadata("/app/spring-boot-app.jar", "spring-boot-app.jar", 50000L, "mainhash"); 146 | 147 | // Add nested JARs 148 | JarMetadata nestedJar1 = new JarMetadata("spring-boot-app.jar!/BOOT-INF/lib/spring-core.jar", "spring-core.jar", 1000L, "hash1"); 149 | JarMetadata nestedJar2 = new JarMetadata("spring-boot-app.jar!/BOOT-INF/lib/spring-context.jar", "spring-context.jar", 2000L, "hash2"); 150 | 151 | app.jars.put(mainJar.fullPath, mainJar); 152 | app.jars.put(nestedJar1.fullPath, nestedJar1); 153 | app.jars.put(nestedJar2.fullPath, nestedJar2); 154 | 155 | assertEquals(3, app.jars.size()); 156 | 157 | // Verify nested JAR detection 158 | assertTrue(nestedJar1.isNested()); 159 | assertTrue(nestedJar2.isNested()); 160 | assertFalse(mainJar.isNested()); 161 | 162 | // Verify container JAR paths 163 | assertEquals("spring-boot-app.jar", nestedJar1.getContainerJarPath()); 164 | assertEquals("spring-boot-app.jar", nestedJar2.getContainerJarPath()); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/handler/JarsHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.server.handler; 2 | 3 | import com.sun.net.httpserver.HttpExchange; 4 | import com.sun.net.httpserver.HttpHandler; 5 | import io.github.brunoborges.jlib.common.JarMetadata; 6 | import io.github.brunoborges.jlib.common.JavaApplication; 7 | import io.github.brunoborges.jlib.server.service.ApplicationService; 8 | import org.json.JSONArray; 9 | import org.json.JSONObject; 10 | 11 | import java.io.IOException; 12 | import java.io.OutputStream; 13 | import java.nio.charset.StandardCharsets; 14 | import java.util.LinkedHashMap; 15 | import java.util.Map; 16 | 17 | /** 18 | * Handler exposing global JAR inventory endpoints: 19 | *

    20 | *
  • GET /api/jars - list all known JARs (deduplicated by jarId)
  • 21 | *
  • GET /api/jars/{jarId} - detail with applications that reference it
  • 22 | *
23 | */ 24 | public class JarsHandler implements HttpHandler { 25 | 26 | private final ApplicationService applicationService; 27 | 28 | public JarsHandler(ApplicationService applicationService) { 29 | this.applicationService = applicationService; 30 | } 31 | 32 | @Override 33 | public void handle(HttpExchange exchange) throws IOException { 34 | if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { 35 | sendPlain(exchange, 405, "Method not allowed"); 36 | return; 37 | } 38 | String path = exchange.getRequestURI().getPath(); 39 | if ("/api/jars".equals(path)) { 40 | handleList(exchange); 41 | } else if (path.startsWith("/api/jars/")) { 42 | String jarId = path.substring("/api/jars/".length()); 43 | handleDetail(exchange, jarId); 44 | } else { 45 | sendPlain(exchange, 404, "Not found"); 46 | } 47 | } 48 | 49 | private void handleList(HttpExchange exchange) throws IOException { 50 | // Deduplicate by jarId, choose first occurrence for basic info and count apps 51 | class Agg { 52 | JarMetadata jar; 53 | int appCount; 54 | int loadedCount; 55 | JSONArray applicationIds = new JSONArray(); 56 | } 57 | Map byId = new LinkedHashMap<>(); 58 | for (JavaApplication app : applicationService.getAllApplications()) { 59 | for (JarMetadata jar : app.jars.values()) { 60 | String id = jar.getJarId(); 61 | Agg agg = byId.computeIfAbsent(id, k -> { 62 | Agg a = new Agg(); 63 | a.jar = jar; 64 | return a; 65 | }); 66 | agg.appCount++; 67 | if (jar.isLoaded()) 68 | agg.loadedCount++; 69 | // Track application id (avoid duplicates if same jar instance re-processed) 70 | // Simple linear check given typically low cardinality per jar. 71 | boolean already = false; 72 | for (int i = 0; i < agg.applicationIds.length(); i++) { 73 | if (app.appId.equals(agg.applicationIds.getString(i))) { already = true; break; } 74 | } 75 | if (!already) { 76 | agg.applicationIds.put(app.appId); 77 | } 78 | } 79 | } 80 | JSONArray arr = new JSONArray(); 81 | for (Map.Entry e : byId.entrySet()) { 82 | JarMetadata jar = e.getValue().jar; 83 | JSONObject o = new JSONObject(); 84 | o.put("jarId", e.getKey()); 85 | o.put("fileName", jar.fileName); 86 | o.put("checksum", jar.sha256Hash); 87 | o.put("size", jar.size); 88 | o.put("appCount", e.getValue().appCount); 89 | o.put("loadedAppCount", e.getValue().loadedCount); 90 | o.put("applicationIds", e.getValue().applicationIds); 91 | arr.put(o); 92 | } 93 | sendJson(exchange, new JSONObject().put("jars", arr).toString()); 94 | } 95 | 96 | private void handleDetail(HttpExchange exchange, String jarId) throws IOException { 97 | JarMetadata representative = null; 98 | JSONArray apps = new JSONArray(); 99 | for (JavaApplication app : applicationService.getAllApplications()) { 100 | for (JarMetadata jar : app.jars.values()) { 101 | if (jar.getJarId().equals(jarId)) { 102 | if (representative == null) 103 | representative = jar; 104 | JSONObject a = new JSONObject(); 105 | a.put("appId", app.appId); 106 | // Include application display name as appName; explicit null if unset to satisfy contract 107 | if (app.name == null) { 108 | a.put("appName", JSONObject.NULL); 109 | } else { 110 | a.put("appName", app.name); 111 | } 112 | a.put("loaded", jar.isLoaded()); 113 | a.put("lastAccessed", jar.getLastAccessed().toString()); 114 | a.put("path", jar.fullPath); 115 | apps.put(a); 116 | } 117 | } 118 | } 119 | if (representative == null) { 120 | sendPlain(exchange, 404, "JAR not found"); 121 | return; 122 | } 123 | JSONObject root = new JSONObject(); 124 | root.put("jarId", jarId); 125 | root.put("fileName", representative.fileName); 126 | root.put("checksum", representative.sha256Hash); 127 | root.put("size", representative.size); 128 | root.put("firstSeen", representative.firstSeen.toString()); 129 | root.put("lastAccessed", representative.getLastAccessed().toString()); 130 | if (representative.getManifestAttributes() != null && !representative.getManifestAttributes().isEmpty()) { 131 | JSONObject mf = new JSONObject(); 132 | for (var e : representative.getManifestAttributes().entrySet()) { 133 | mf.put(e.getKey(), e.getValue()); 134 | } 135 | root.put("manifest", mf); 136 | } 137 | root.put("applications", apps); 138 | sendJson(exchange, root.toString()); 139 | } 140 | 141 | private void sendPlain(HttpExchange ex, int code, String msg) throws IOException { 142 | byte[] b = msg.getBytes(StandardCharsets.UTF_8); 143 | ex.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8"); 144 | ex.sendResponseHeaders(code, b.length); 145 | try (OutputStream os = ex.getResponseBody()) { 146 | os.write(b); 147 | } 148 | } 149 | 150 | private void sendJson(HttpExchange ex, String json) throws IOException { 151 | byte[] b = json.getBytes(StandardCharsets.UTF_8); 152 | ex.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); 153 | ex.sendResponseHeaders(200, b.length); 154 | try (OutputStream os = ex.getResponseBody()) { 155 | os.write(b); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/JLibServer.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.server; 2 | 3 | import com.sun.net.httpserver.HttpServer; 4 | 5 | import io.github.brunoborges.jlib.common.ApplicationIdUtil; 6 | import io.github.brunoborges.jlib.server.handler.AppsHandler; 7 | import io.github.brunoborges.jlib.server.handler.HealthHandler; 8 | import io.github.brunoborges.jlib.server.handler.DashboardHandler; 9 | import io.github.brunoborges.jlib.server.service.ApplicationService; 10 | import io.github.brunoborges.jlib.server.service.JarService; 11 | import io.github.brunoborges.jlib.server.handler.ReportHandler; 12 | import io.github.brunoborges.jlib.server.handler.JarsHandler; 13 | 14 | import java.io.IOException; 15 | import java.net.InetSocketAddress; 16 | import java.util.List; 17 | import java.util.concurrent.Executors; 18 | import java.util.logging.Logger; 19 | 20 | /** 21 | * HTTP Server for tracking Java applications and their JAR file usage. 22 | * 23 | *

24 | * This server receives push events from Java agents running in other JVM 25 | * processes 26 | * and provides a REST API for querying application and JAR information. 27 | * 28 | *

API Endpoints:

29 | *
    30 | *
  • GET /api/apps - List all tracked applications
  • 31 | *
  • GET /api/apps/{appId} - Get a specific application's details
  • 32 | *
  • GET /api/apps/{appId}/jars - List JARs for a specific application
  • 33 | *
  • GET /api/jars - List all known JARs (deduplicated by jarId)
  • 34 | *
  • GET /api/jars/{jarId} - Get details for a specific JAR across applications
  • 35 | *
  • PUT /api/apps/{appId} - Register/update an application and its JARs
  • 36 | *
  • PUT /api/apps/{appId}/metadata - Update application metadata (name, description, tags)
  • 37 | *
  • GET /report - Aggregated unique JARs across applications
  • 38 | *
  • GET /health - Health check endpoint
  • 39 | *
40 | * 41 | *

Application Data Model:

42 | *

43 | * Each Java application is identified by a hash ID computed from: 44 | *

    45 | *
  • JVM command line arguments
  • 46 | *
  • Checksums of all JAR files mentioned in the command line
  • 47 | *
  • JDK version
  • 48 | *
49 | */ 50 | public class JLibServer { 51 | 52 | private static final Logger logger = Logger.getLogger(JLibServer.class.getName()); 53 | private static final int PORT = 8080; 54 | 55 | private HttpServer server; 56 | private ApplicationService applicationService; 57 | private JarService jarService; 58 | 59 | /** 60 | * Starts the HTTP server on the specified port. 61 | */ 62 | public void start(int port) throws IOException { 63 | // Initialize services 64 | applicationService = new ApplicationService(); 65 | jarService = new JarService(); 66 | 67 | // Create HTTP server 68 | server = HttpServer.create(new InetSocketAddress(port), 0); 69 | server.setExecutor(Executors.newCachedThreadPool()); 70 | 71 | // Configure handlers with dependency injection 72 | server.createContext("/api/apps", new AppsHandler(applicationService, jarService)); 73 | server.createContext("/api/jars", new JarsHandler(applicationService)); 74 | server.createContext("/api/dashboard", new DashboardHandler(applicationService)); 75 | server.createContext("/health", new HealthHandler(applicationService)); 76 | server.createContext("/report", new ReportHandler(applicationService)); 77 | 78 | server.start(); 79 | logger.info("JLib Server started on port " + port); 80 | } 81 | 82 | /** 83 | * Stops the HTTP server. 84 | */ 85 | public void stop() { 86 | if (server != null) { 87 | server.stop(0); 88 | logger.info("JLib Server stopped"); 89 | } 90 | } 91 | 92 | /** 93 | * Registers a new Java application with the server. 94 | * 95 | * @param name The application name 96 | * @param commandLine The command line used to start the application 97 | * @param jarPaths List of JAR file paths on the classpath 98 | * @param jdkVersion The JDK version 99 | * @param jarChecksums List of checksums for the top-level JAR files 100 | */ 101 | public void registerApplication(String name, String commandLine, List jarPaths, String jdkVersion, 102 | List jarChecksums) { 103 | String applicationId = ApplicationIdUtil.computeApplicationId(commandLine, jarChecksums, jdkVersion, "unknown", 104 | "unknown"); 105 | applicationService.getOrCreateApplication(applicationId, commandLine, jdkVersion, "unknown", "unknown"); 106 | logger.info("Registered application: " + applicationId + " (" + name + ")"); 107 | } 108 | 109 | /** 110 | * Updates JAR information for a specific application. 111 | * 112 | * @param applicationId The application ID 113 | * @param jarPath The JAR file path 114 | * @param jarHash The JAR file hash 115 | */ 116 | public void updateJarInApplication(String applicationId, String jarPath, String jarHash) { 117 | // For now, just log this - we'll need to enhance JarService for individual JAR 118 | // updates 119 | logger.info("JAR update for app " + applicationId + ": " + jarPath + " (hash: " + jarHash + ")"); 120 | } 121 | 122 | /** 123 | * Main entry point for running the server. 124 | */ 125 | public static void main(String[] args) { 126 | // Print production warning 127 | System.out.println("════════════════════════════════════════════════════════════════════"); 128 | System.out.println("⚠️ WARNING: EXPERIMENTAL SOFTWARE - NOT PRODUCTION READY"); 129 | System.out.println(" This software is in development and should only be used for"); 130 | System.out.println(" development, testing, and evaluation purposes."); 131 | System.out.println(" Do not use in production environments."); 132 | System.out.println("════════════════════════════════════════════════════════════════════"); 133 | System.out.println(); 134 | 135 | int port = PORT; 136 | if (args.length > 0) { 137 | try { 138 | port = Integer.parseInt(args[0]); 139 | } catch (NumberFormatException e) { 140 | System.err.println("Invalid port number: " + args[0] + ". Using default port " + PORT); 141 | } 142 | } 143 | 144 | JLibServer server = new JLibServer(); 145 | try { 146 | server.start(port); 147 | 148 | // Keep the server running 149 | System.out.println("JLib Server is running on port " + port); 150 | System.out.println("Press Ctrl+C to stop the server"); 151 | 152 | // Add shutdown hook 153 | Runtime.getRuntime().addShutdownHook(new Thread(server::stop)); 154 | 155 | // Keep main thread alive 156 | Thread.currentThread().join(); 157 | 158 | } catch (IOException e) { 159 | System.err.println("Failed to start server: " + e.getMessage()); 160 | System.exit(1); 161 | } catch (InterruptedException e) { 162 | Thread.currentThread().interrupt(); 163 | server.stop(); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /server/src/main/java/io/github/brunoborges/jlib/server/handler/ReportHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.server.handler; 2 | 3 | import com.sun.net.httpserver.HttpExchange; 4 | import com.sun.net.httpserver.HttpHandler; 5 | 6 | import io.github.brunoborges.jlib.common.JarMetadata; 7 | import io.github.brunoborges.jlib.common.JavaApplication; 8 | import io.github.brunoborges.jlib.server.service.ApplicationService; 9 | 10 | import java.io.IOException; 11 | import java.io.OutputStream; 12 | import java.nio.charset.StandardCharsets; 13 | import java.time.Instant; 14 | import java.util.ArrayList; 15 | import java.util.HashMap; 16 | import java.util.HashSet; 17 | import java.util.List; 18 | import java.util.Map; 19 | import java.util.Set; 20 | import org.json.JSONArray; 21 | import org.json.JSONObject; 22 | 23 | /** 24 | * HTTP handler for /report endpoint that aggregates unique JARs across 25 | * applications. 26 | */ 27 | public class ReportHandler implements HttpHandler { 28 | 29 | private final ApplicationService applicationService; 30 | // Deprecated parser usage removed. 31 | 32 | public ReportHandler(ApplicationService applicationService) { 33 | this.applicationService = applicationService; 34 | } 35 | 36 | @Override 37 | public void handle(HttpExchange exchange) throws IOException { 38 | if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { 39 | send(exchange, 405, "Method not allowed"); 40 | return; 41 | } 42 | 43 | // Aggregate by checksum when available; fallback to fullPath 44 | class Agg { 45 | String key; // checksum or fullPath 46 | String checksum; 47 | long size = -1L; 48 | String sampleFileName; 49 | Instant firstSeen = null; 50 | Instant lastAccessed = null; 51 | int loadedCount = 0; 52 | final Set paths = new HashSet<>(); 53 | final Set fileNames = new HashSet<>(); 54 | final List> applications = new ArrayList<>(); 55 | } 56 | 57 | Map aggByKey = new HashMap<>(); 58 | 59 | for (JavaApplication app : applicationService.getAllApplications()) { 60 | for (JarMetadata jar : app.jars.values()) { 61 | if (!jar.isLoaded()) { 62 | continue; // only include applications that loaded the jar 63 | } 64 | String checksum = jar.sha256Hash; 65 | boolean hasHash = checksum != null && !checksum.isEmpty() && !"?".equals(checksum); 66 | String key = hasHash ? checksum : jar.fullPath; 67 | 68 | Agg agg = aggByKey.computeIfAbsent(key, k -> { 69 | Agg a = new Agg(); 70 | a.key = k; 71 | a.checksum = hasHash ? checksum : null; 72 | a.size = jar.size; 73 | a.sampleFileName = jar.fileName; 74 | a.firstSeen = jar.firstSeen; 75 | a.lastAccessed = jar.getLastAccessed(); 76 | return a; 77 | }); 78 | 79 | // Update aggregate 80 | agg.paths.add(jar.fullPath); 81 | agg.fileNames.add(jar.fileName); 82 | if (agg.size < 0 && jar.size >= 0) 83 | agg.size = jar.size; 84 | if (agg.sampleFileName == null) 85 | agg.sampleFileName = jar.fileName; 86 | if (agg.firstSeen == null || jar.firstSeen.isBefore(agg.firstSeen)) 87 | agg.firstSeen = jar.firstSeen; 88 | if (agg.lastAccessed == null || jar.getLastAccessed().isAfter(agg.lastAccessed)) 89 | agg.lastAccessed = jar.getLastAccessed(); 90 | agg.loadedCount++; 91 | 92 | Map appInfo = new HashMap<>(); 93 | appInfo.put("key", agg.key); 94 | appInfo.put("appId", app.appId); 95 | appInfo.put("jdkVersion", app.jdkVersion); 96 | appInfo.put("jdkVendor", app.jdkVendor); 97 | appInfo.put("jdkPath", app.jdkPath); 98 | appInfo.put("firstSeen", app.firstSeen.toString()); 99 | appInfo.put("lastUpdated", app.lastUpdated.toString()); 100 | appInfo.put("jarPath", jar.fullPath); 101 | appInfo.put("loaded", jar.isLoaded()); 102 | appInfo.put("lastAccessed", jar.getLastAccessed().toString()); 103 | agg.applications.add(appInfo); 104 | } 105 | } 106 | 107 | JSONArray uniqueJars = new JSONArray(); 108 | for (Agg agg : aggByKey.values()) { 109 | JSONObject obj = new JSONObject(); 110 | if (agg.checksum != null) 111 | obj.put("checksum", agg.checksum); 112 | obj.put("size", agg.size); 113 | obj.put("fileName", agg.sampleFileName == null ? "" : agg.sampleFileName); 114 | obj.put("firstSeen", agg.firstSeen == null ? "" : agg.firstSeen.toString()); 115 | obj.put("lastAccessed", agg.lastAccessed == null ? "" : agg.lastAccessed.toString()); 116 | obj.put("loadedCount", agg.loadedCount); 117 | 118 | JSONArray pathsArr = new JSONArray(); 119 | for (String p : agg.paths) 120 | pathsArr.put(p); 121 | obj.put("paths", pathsArr); 122 | 123 | JSONArray fnArr = new JSONArray(); 124 | for (String fn : agg.fileNames) 125 | fnArr.put(fn); 126 | obj.put("fileNames", fnArr); 127 | 128 | JSONArray appsArr = new JSONArray(); 129 | for (Map ai : agg.applications) { 130 | JSONObject jo = new JSONObject(); 131 | jo.put("appId", ai.get("appId")); 132 | jo.put("jdkVersion", ai.get("jdkVersion")); 133 | jo.put("jdkVendor", ai.get("jdkVendor")); 134 | jo.put("jdkPath", ai.get("jdkPath")); 135 | jo.put("firstSeen", ai.get("firstSeen")); 136 | jo.put("lastUpdated", ai.get("lastUpdated")); 137 | jo.put("jarPath", ai.get("jarPath")); 138 | jo.put("loaded", ai.get("loaded")); 139 | jo.put("lastAccessed", ai.get("lastAccessed")); 140 | appsArr.put(jo); 141 | } 142 | obj.put("applications", appsArr); 143 | uniqueJars.put(obj); 144 | } 145 | 146 | JSONObject root = new JSONObject().put("uniqueJars", uniqueJars); 147 | byte[] bytes = root.toString().getBytes(StandardCharsets.UTF_8); 148 | exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); 149 | exchange.sendResponseHeaders(200, bytes.length); 150 | try (OutputStream os = exchange.getResponseBody()) { 151 | os.write(bytes); 152 | } 153 | } 154 | 155 | private void send(HttpExchange ex, int code, String msg) throws IOException { 156 | byte[] b = msg.getBytes(StandardCharsets.UTF_8); 157 | ex.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8"); 158 | ex.sendResponseHeaders(code, b.length); 159 | try (OutputStream os = ex.getResponseBody()) { 160 | os.write(b); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /demo-jlib-inspector.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | # Test the unified InspectorAgent with optional server integration 3 | 4 | Write-Host "=== Unified InspectorAgent Demo ===" -ForegroundColor Cyan 5 | Write-Host "Testing single agent class with optional server functionality" -ForegroundColor Yellow 6 | Write-Host "" 7 | 8 | # Build the project 9 | Write-Host "1. Building project..." -ForegroundColor Green 10 | cd "D:\work\jlib-inspector" 11 | mvn verify -q 12 | if ($LASTEXITCODE -ne 0) { 13 | Write-Host "Build failed!" -ForegroundColor Red 14 | exit 1 15 | } 16 | 17 | Write-Host "2. Testing local-only mode (no server arguments)..." -ForegroundColor Green 18 | Write-Host " Running: java -javaagent:agent.jar -jar ...\sample-spring-app-1.0-SNAPSHOT.jar" -ForegroundColor Gray 19 | 20 | $agentPath = "agent\target\jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar" 21 | $serverPath = "server\target\jlib-inspector-server-1.0-SNAPSHOT-shaded.jar" 22 | $springJar = "sample-spring-app\target\sample-spring-app-1.0-SNAPSHOT.jar" 23 | 24 | # Test 1: Local-only mode (no args) 25 | $output1 = & java -javaagent:$agentPath -jar $springJar 2>&1 | Out-String 26 | if ($output1 -match "Total JARs\s+:\s+(\d+)") { 27 | Write-Host " ✓ Local mode: Found $($matches[1]) JARs" -ForegroundColor Green 28 | } else { 29 | Write-Host " ✗ Local mode: No JAR summary found" -ForegroundColor Red 30 | } 31 | 32 | Write-Host "" 33 | Write-Host "3. Testing server mode with non-existent server..." -ForegroundColor Green 34 | Write-Host " Running: java -javaagent:agent.jar=server:8080 -jar spring-app.jar" -ForegroundColor Gray 35 | 36 | # Test 2: Server mode (should fail gracefully because server is NOT running) 37 | $output2 = & java "-javaagent:$agentPath=server:8080" -jar $springJar 2>&1 | Out-String 38 | if ($output2 -match "Total JARs\s+:\s+(\d+)") { 39 | Write-Host " ✓ Server mode: Found $($matches[1]) JARs (local report still works)" -ForegroundColor Green 40 | } else { 41 | Write-Host " ✗ Server mode: No JAR summary found" -ForegroundColor Red 42 | } 43 | 44 | if ($output2 -match "Failed to send data to server") { 45 | Write-Host " ✓ Server mode: Gracefully handled unreachable server" -ForegroundColor Green 46 | } else { 47 | Write-Host " ! Server mode: Couldn't verify failure message (may vary)" -ForegroundColor Yellow 48 | } 49 | 50 | Write-Host "" 51 | Write-Host "4. Starting HTTP server for integration tests..." -ForegroundColor Green 52 | 53 | # Start server with shaded server JAR 54 | $serverJob = Start-Job -ScriptBlock { 55 | param($serverPath) 56 | java -jar $serverPath 8080 57 | } -ArgumentList $serverPath 58 | 59 | # Wait for server to be ready 60 | Write-Host " Waiting for server to start..." -ForegroundColor Gray 61 | $serverReady = $false 62 | $maxAttempts = 15 # 15 seconds timeout 63 | $attempt = 0 64 | 65 | while (-not $serverReady -and $attempt -lt $maxAttempts) { 66 | Start-Sleep -Seconds 1 67 | $attempt++ 68 | try { 69 | $health = Invoke-RestMethod -Uri "http://localhost:8080/health" -Method Get -TimeoutSec 2 70 | if ($health.status -eq "healthy") { 71 | $serverReady = $true 72 | Write-Host " ✓ Server is ready (attempt $attempt)" -ForegroundColor Green 73 | } 74 | } catch { 75 | Write-Host " . Server not ready yet (attempt $attempt)..." -ForegroundColor Gray 76 | } 77 | } 78 | 79 | if (-not $serverReady) { 80 | Write-Host " ✗ Server failed to start within $maxAttempts seconds" -ForegroundColor Red 81 | Stop-Job $serverJob 2>$null 82 | Remove-Job $serverJob 2>$null 83 | exit 1 84 | } 85 | 86 | Write-Host "" 87 | Write-Host "5. Testing server integration mode..." -ForegroundColor Green 88 | Write-Host " Running: java -javaagent:agent.jar=server:8080 -jar spring-app.jar" -ForegroundColor Gray 89 | 90 | # Test 3: Full server integration (server is already running and verified) 91 | $output3 = & java "-javaagent:$agentPath=server:8080" -jar $springJar 2>&1 | Out-String 92 | if ($output3 -match "Total JARs\s+:\s+(\d+)") { 93 | Write-Host " ✓ Server integration: Found $($matches[1]) JARs" -ForegroundColor Green 94 | } 95 | 96 | if ($output3 -match "Successfully sent data to server") { 97 | Write-Host " ✓ Server integration: Successfully sent data to server" -ForegroundColor Green 98 | } elseif ($output3 -match "Application ID:.*will report to") { 99 | Write-Host " ✓ Server integration: Generated application ID and configured server" -ForegroundColor Green 100 | } 101 | 102 | # Check what was registered 103 | Start-Sleep -Seconds 1 104 | $apps = Invoke-RestMethod -Uri "http://localhost:8080/api/apps" -Method Get 105 | if ($apps.applications.Count -gt 0) { 106 | Write-Host " ✓ Server integration: $($apps.applications.Count) application(s) registered" -ForegroundColor Green 107 | $app = $apps.applications[0] 108 | Write-Host " App ID: $($app.appId)" -ForegroundColor Gray 109 | Write-Host " JDK: $($app.jdkVersion)" -ForegroundColor Gray 110 | 111 | # Verify /report reflects this application 112 | $report = Invoke-RestMethod -Uri "http://localhost:8080/report" -Method Get 113 | if ($report.uniqueJars -and $report.uniqueJars.Count -ge 0) { 114 | $containsApp = $false 115 | foreach ($jar in $report.uniqueJars) { 116 | foreach ($a in $jar.applications) { 117 | if ($a.appId -eq $app.appId) { $containsApp = $true; break } 118 | } 119 | if ($containsApp) { break } 120 | } 121 | if ($containsApp) { 122 | Write-Host " ✓ /report: Includes application $($app.appId) in uniqueJars" -ForegroundColor Green 123 | } else { 124 | Write-Host " ! /report: uniqueJars present but appId not found (timing?)" -ForegroundColor Yellow 125 | } 126 | } else { 127 | Write-Host " ✗ /report: Missing uniqueJars array" -ForegroundColor Red 128 | } 129 | } else { 130 | Write-Host " ! Server integration: No applications found (timing issue?)" -ForegroundColor Yellow 131 | } 132 | 133 | Write-Host "" 134 | Write-Host "6. Testing custom host:port format..." -ForegroundColor Green 135 | Write-Host " Running: java -javaagent:agent.jar=server:localhost:8080 -jar spring-app.jar" -ForegroundColor Gray 136 | 137 | # Test 4: Custom host:port format 138 | $output4 = & java "-javaagent:$agentPath=server:localhost:8080" -jar $springJar 2>&1 | Out-String 139 | if ($output4 -match "will report to localhost:8080") { 140 | Write-Host " ✓ Custom format: Correctly parsed host:port" -ForegroundColor Green 141 | } 142 | 143 | Write-Host "" 144 | Write-Host "7. Cleanup..." -ForegroundColor Green 145 | Stop-Job $serverJob 2>$null 146 | Remove-Job $serverJob 2>$null 147 | Write-Host " Server stopped" -ForegroundColor Gray 148 | 149 | Write-Host "" 150 | Write-Host "=== Demo Summary ===" -ForegroundColor Cyan 151 | Write-Host "✓ Single unified InspectorAgent class" -ForegroundColor Green 152 | Write-Host "✓ Local-only mode (no arguments)" -ForegroundColor Green 153 | Write-Host "✓ Server mode with graceful fallback" -ForegroundColor Green 154 | Write-Host "✓ Full server integration when available" -ForegroundColor Green 155 | Write-Host "✓ Flexible argument parsing (server:port and server:host:port)" -ForegroundColor Green 156 | Write-Host "" 157 | Write-Host "Usage Examples:" -ForegroundColor Yellow 158 | Write-Host " -javaagent:jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar # Local only" -ForegroundColor Gray 159 | Write-Host " -javaagent:jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar=server:8080 # Report to localhost:8080" -ForegroundColor Gray 160 | Write-Host " -javaagent:jlib-inspector-agent-1.0-SNAPSHOT-shaded.jar=server:remote:9000 # Report to remote:9000" -ForegroundColor Gray 161 | -------------------------------------------------------------------------------- /server/src/test/java/io/github/brunoborges/jlib/server/ApplicationServiceTest.java: -------------------------------------------------------------------------------- 1 | package io.github.brunoborges.jlib.server; 2 | 3 | import io.github.brunoborges.jlib.common.JavaApplication; 4 | import io.github.brunoborges.jlib.server.service.ApplicationService; 5 | 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.DisplayName; 9 | import static org.junit.jupiter.api.Assertions.*; 10 | 11 | import java.util.Collection; 12 | import java.util.Set; 13 | import java.util.stream.Collectors; 14 | 15 | /** 16 | * Unit tests for ApplicationService. 17 | */ 18 | @DisplayName("ApplicationService Tests") 19 | class ApplicationServiceTest { 20 | 21 | private ApplicationService applicationService; 22 | 23 | @BeforeEach 24 | void setUp() { 25 | applicationService = new ApplicationService(); 26 | } 27 | 28 | @Test 29 | @DisplayName("Should create new application when not exists") 30 | void shouldCreateNewApplicationWhenNotExists() { 31 | String appId = "test-app-123"; 32 | String commandLine = "java -jar myapp.jar"; 33 | String jdkVersion = "17.0.2"; 34 | String jdkVendor = "Eclipse Adoptium"; 35 | String jdkPath = "/usr/lib/jvm/java-17"; 36 | 37 | JavaApplication app = applicationService.getOrCreateApplication(appId, commandLine, jdkVersion, jdkVendor, jdkPath); 38 | 39 | assertNotNull(app); 40 | assertEquals(appId, app.appId); 41 | assertEquals(commandLine, app.commandLine); 42 | assertEquals(jdkVersion, app.jdkVersion); 43 | assertEquals(jdkVendor, app.jdkVendor); 44 | assertEquals(jdkPath, app.jdkPath); 45 | } 46 | 47 | @Test 48 | @DisplayName("Should return existing application when already exists") 49 | void shouldReturnExistingApplicationWhenAlreadyExists() { 50 | String appId = "existing-app"; 51 | 52 | // Create application first time 53 | JavaApplication app1 = applicationService.getOrCreateApplication(appId, "java -jar app.jar", "17", "OpenJDK", "/java"); 54 | 55 | // Get same application with different parameters (should ignore new params) 56 | JavaApplication app2 = applicationService.getOrCreateApplication(appId, "java -jar different.jar", "11", "Oracle", "/different"); 57 | 58 | assertSame(app1, app2); 59 | assertEquals("java -jar app.jar", app2.commandLine); // Original command line 60 | assertEquals("17", app2.jdkVersion); // Original JDK version 61 | } 62 | 63 | @Test 64 | @DisplayName("Should get application by ID") 65 | void shouldGetApplicationById() { 66 | String appId = "test-app"; 67 | 68 | // Create application 69 | JavaApplication created = applicationService.getOrCreateApplication(appId, "java -jar test.jar", "17", "OpenJDK", "/java"); 70 | 71 | // Get by ID 72 | JavaApplication retrieved = applicationService.getApplication(appId); 73 | 74 | assertSame(retrieved, created); 75 | } 76 | 77 | @Test 78 | @DisplayName("Should return null for non-existent application") 79 | void shouldReturnNullForNonExistentApplication() { 80 | JavaApplication app = applicationService.getApplication("non-existent"); 81 | assertNull(app); 82 | } 83 | 84 | @Test 85 | @DisplayName("Should return all applications") 86 | void shouldReturnAllApplications() { 87 | // Initially empty 88 | Collection apps = applicationService.getAllApplications(); 89 | assertTrue(apps.isEmpty()); 90 | 91 | // Add some applications 92 | applicationService.getOrCreateApplication("app1", "java -jar app1.jar", "17", "OpenJDK", "/java"); 93 | applicationService.getOrCreateApplication("app2", "java -jar app2.jar", "11", "Oracle", "/oracle"); 94 | applicationService.getOrCreateApplication("app3", "java -jar app3.jar", "8", "AdoptOpenJDK", "/adopt"); 95 | 96 | apps = applicationService.getAllApplications(); 97 | assertEquals(3, apps.size()); 98 | 99 | // Verify all apps are present 100 | Set appIds = apps.stream().map(app -> app.appId).collect(Collectors.toSet()); 101 | assertEquals(Set.of("app1", "app2", "app3"), appIds); 102 | } 103 | 104 | @Test 105 | @DisplayName("Should return application count") 106 | void shouldReturnApplicationCount() { 107 | assertEquals(0, applicationService.getApplicationCount()); 108 | 109 | applicationService.getOrCreateApplication("app1", "java -jar app1.jar", "17", "OpenJDK", "/java"); 110 | assertEquals(1, applicationService.getApplicationCount()); 111 | 112 | applicationService.getOrCreateApplication("app2", "java -jar app2.jar", "11", "Oracle", "/oracle"); 113 | assertEquals(2, applicationService.getApplicationCount()); 114 | 115 | // Adding same app should not increase count 116 | applicationService.getOrCreateApplication("app1", "java -jar different.jar", "11", "Different", "/different"); 117 | assertEquals(2, applicationService.getApplicationCount()); 118 | } 119 | 120 | @Test 121 | @DisplayName("Should handle concurrent access safely") 122 | void shouldHandleConcurrentAccessSafely() throws InterruptedException { 123 | int threadCount = 10; 124 | String appId = "concurrent-app"; 125 | Thread[] threads = new Thread[threadCount]; 126 | JavaApplication[] results = new JavaApplication[threadCount]; 127 | 128 | // Start multiple threads trying to create the same application 129 | for (int i = 0; i < threadCount; i++) { 130 | final int index = i; 131 | threads[i] = new Thread(() -> { 132 | results[index] = applicationService.getOrCreateApplication( 133 | appId, "java -jar concurrent.jar", "17", "OpenJDK", "/java"); 134 | }); 135 | threads[i].start(); 136 | } 137 | 138 | // Wait for all threads to complete 139 | for (Thread thread : threads) { 140 | thread.join(); 141 | } 142 | 143 | // All threads should have received the same instance 144 | for (int i = 1; i < threadCount; i++) { 145 | assertSame(results[0], results[i]); 146 | } 147 | 148 | // Should have only one application 149 | assertEquals(1, applicationService.getApplicationCount()); 150 | } 151 | 152 | @Test 153 | @DisplayName("Should handle null and empty values gracefully") 154 | void shouldHandleNullAndEmptyValuesGracefully() { 155 | // ConcurrentHashMap doesn't allow null keys, so null appId should throw NPE 156 | assertThrows(NullPointerException.class, () -> 157 | applicationService.getOrCreateApplication(null, null, null, null, null)); 158 | 159 | // Empty string should work fine 160 | JavaApplication app2 = applicationService.getOrCreateApplication("", "", "", "", ""); 161 | assertNotNull(app2); 162 | assertTrue(app2.appId.isEmpty()); 163 | 164 | // Null queries should also throw NPE 165 | assertThrows(NullPointerException.class, () -> applicationService.getApplication(null)); 166 | 167 | assertSame(app2, applicationService.getApplication("")); 168 | assertEquals(1, applicationService.getApplicationCount()); 169 | } 170 | 171 | @Test 172 | @DisplayName("Should preserve application data integrity") 173 | void shouldPreserveApplicationDataIntegrity() { 174 | String appId = "data-integrity-test"; 175 | JavaApplication app = applicationService.getOrCreateApplication( 176 | appId, "java -jar test.jar", "17", "OpenJDK", "/java"); 177 | 178 | // Modify application data 179 | app.lastUpdated = java.time.Instant.now(); 180 | app.jars.put("/test.jar", new io.github.brunoborges.jlib.common.JarMetadata( 181 | "/test.jar", "test.jar", 1000L, "hash123")); 182 | 183 | // Retrieve again - should be same instance with modifications 184 | JavaApplication retrieved = applicationService.getApplication(appId); 185 | assertSame(app, retrieved); 186 | assertEquals(1, retrieved.jars.size()); 187 | assertNotNull(retrieved.jars.get("/test.jar")); 188 | } 189 | } 190 | --------------------------------------------------------------------------------