full refactor

This commit is contained in:
Radek Davidek 2026-04-05 21:34:40 +02:00
parent 1fe1137f80
commit 7d8c9be9e6
13 changed files with 803 additions and 627 deletions

View File

@ -1,39 +1,29 @@
package cz.kamma.processmonitor; package cz.kamma.processmonitor;
import com.google.gson.Gson; import cz.kamma.processmonitor.config.AppConfig;
import com.google.gson.JsonSyntaxException; import cz.kamma.processmonitor.handler.DashboardHandler;
import com.sun.net.httpserver.Headers; import cz.kamma.processmonitor.handler.DataApiHandler;
import com.sun.net.httpserver.HttpExchange; import cz.kamma.processmonitor.handler.HeartbeatHandler;
import com.sun.net.httpserver.HttpHandler; import cz.kamma.processmonitor.repository.ProcessRepository;
import cz.kamma.processmonitor.service.ProcessService;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
public class Main { public class Main {
private static final Gson GSON = new Gson();
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
AppConfig config = AppConfig.load(); AppConfig config = AppConfig.load();
Database database = new Database(config); ProcessRepository repository = new ProcessRepository(config);
ProcessService processService = new ProcessService();
HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", 8080), 0); HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", 8080), 0);
server.createContext("/hb/api", new HeartbeatHandler(database));
server.createContext("/hb/api", new HeartbeatHandler(repository));
server.createContext("/hb/dashboard", new DashboardHandler(config)); server.createContext("/hb/dashboard", new DashboardHandler(config));
server.createContext("/hb/api/data", new DataApiHandler(database, config)); server.createContext("/hb/api/data", new DataApiHandler(config, repository, processService));
server.setExecutor(null); server.setExecutor(null);
server.start(); server.start();
@ -42,562 +32,7 @@ public class Main {
log("API endpoint: http://127.0.0.1:8080/hb/api"); log("API endpoint: http://127.0.0.1:8080/hb/api");
} }
private static final class HeartbeatHandler implements HttpHandler {
private final Database database;
private HeartbeatHandler(Database database) {
this.database = database;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
sendJson(exchange, 405, "{\"error\":\"method_not_allowed\"}");
return;
}
Headers headers = exchange.getRequestHeaders();
String contentType = headers.getFirst("Content-Type");
if (contentType == null || !contentType.toLowerCase().contains("application/json")) {
sendJson(exchange, 415, "{\"error\":\"unsupported_media_type\"}");
return;
}
String body = readBody(exchange.getRequestBody());
log("Incoming heartbeat request: " + body);
HeartbeatRequest request = HeartbeatRequest.parse(body);
int insertedRows = database.saveHeartbeat(request);
log("Stored heartbeat for machine " + request.machineName + ", inserted rows: " + insertedRows);
sendJson(exchange, 200, String.format("{\"ok\":true,\"inserted\":%d}", insertedRows));
} catch (IllegalArgumentException ex) {
log("Request validation failed: " + ex.getMessage());
sendJson(exchange, 400, "{\"error\":\"" + escapeJson(ex.getMessage()) + "\"}");
} catch (Exception ex) {
ex.printStackTrace();
log("Internal server error: " + ex.getMessage());
sendJson(exchange, 500, "{\"error\":\"internal_error\"}");
}
}
private static String readBody(InputStream inputStream) throws IOException {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
private static void sendJson(HttpExchange exchange, int statusCode, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(bytes);
}
}
}
private static final class AppConfig {
private final String jdbcUrl;
private final String dbUser;
private final String dbPassword;
private final String dashboardApiKey;
private AppConfig(String jdbcUrl, String dbUser, String dbPassword, String dashboardApiKey) {
this.jdbcUrl = jdbcUrl;
this.dbUser = dbUser;
this.dbPassword = dbPassword;
this.dashboardApiKey = dashboardApiKey;
}
private static AppConfig load() throws IOException {
Properties properties = new Properties();
try (InputStream inputStream = Main.class.getClassLoader().getResourceAsStream("application.properties")) {
if (inputStream == null) {
throw new IOException("Missing application.properties");
}
properties.load(inputStream);
}
String jdbcUrl = required(properties, "db.url");
String dbUser = required(properties, "db.user");
String dbPassword = required(properties, "db.password");
String dashboardApiKey = required(properties, "dashboard.apiKey");
return new AppConfig(jdbcUrl, dbUser, dbPassword, dashboardApiKey);
}
private static String required(Properties properties, String key) {
String value = properties.getProperty(key);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Missing config key: " + key);
}
return value.trim();
}
}
private static final class Database {
private final AppConfig config;
private Database(AppConfig config) {
this.config = config;
}
private int saveHeartbeat(HeartbeatRequest request) throws SQLException {
List<String> processes = request.processes.isEmpty() ? Collections.singletonList(null) : request.processes;
String sql = "INSERT INTO process_heartbeat (machine_name, status, detected_at, process_name) VALUES (?, ?, ?, ?)";
try (Connection connection = DriverManager.getConnection(config.jdbcUrl, config.dbUser, config.dbPassword);
PreparedStatement statement = connection.prepareStatement(sql)) {
int inserted = 0;
for (String processName : processes) {
statement.setString(1, request.machineName);
statement.setString(2, request.status);
statement.setTimestamp(3, Timestamp.from(request.detectedAt.atZone(ZoneId.systemDefault()).toInstant()));
statement.setString(4, processName);
inserted += statement.executeUpdate();
}
return inserted;
}
}
private FilterOptions getFilterOptions() throws SQLException {
String sqlMachines = "SELECT DISTINCT machine_name FROM process_heartbeat ORDER BY machine_name";
String sqlProcesses = "SELECT DISTINCT process_name FROM process_heartbeat WHERE process_name IS NOT NULL ORDER BY process_name";
String sqlStatuses = "SELECT DISTINCT status FROM process_heartbeat ORDER BY status";
try (Connection connection = DriverManager.getConnection(config.jdbcUrl, config.dbUser, config.dbPassword)) {
List<String> machines = new java.util.ArrayList<>();
try (PreparedStatement stmt = connection.prepareStatement(sqlMachines);
java.sql.ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
machines.add(rs.getString(1));
}
}
List<String> processes = new java.util.ArrayList<>();
try (PreparedStatement stmt = connection.prepareStatement(sqlProcesses);
java.sql.ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
processes.add(rs.getString(1));
}
}
List<String> statuses = new java.util.ArrayList<>();
try (PreparedStatement stmt = connection.prepareStatement(sqlStatuses);
java.sql.ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
statuses.add(rs.getString(1));
}
}
return new FilterOptions(machines, processes, statuses);
}
}
private String extractMainName(String processName, java.util.List<String> allProcessNames) {
if (processName == null || processName.isBlank()) {
return null;
}
// Odstranit .exe
String name = processName;
int dotIndex = name.lastIndexOf('.');
if (dotIndex > 0) {
name = name.substring(0, dotIndex);
}
// Najít všechny unikátní názvy bez .exe
java.util.Set<String> uniqueNames = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
for (String procName : allProcessNames) {
if (procName == null || procName.isBlank()) continue;
int dotIdx = procName.lastIndexOf('.');
if (dotIdx > 0) {
uniqueNames.add(procName.substring(0, dotIdx));
} else {
uniqueNames.add(procName);
}
}
// Najít skupinu procesů, které patří k tomuto (mají společnou předponu)
java.util.List<String> group = new java.util.ArrayList<>();
for (String n : uniqueNames) {
// Zkontrolovat, zda název začíná na stejný základ jako target nebo naopak
if (hasCommonPrefix(name, n)) {
group.add(n);
}
}
// Pokud je jen jeden v group, vrať ho
if (group.size() == 1) {
return group.get(0);
}
// Najít nejkratší společnou předponu pro tuto skupinu
group.sort((a, b) -> Integer.compare(a.length(), b.length()));
String shortest = group.get(0);
while (shortest.length() > 0) {
boolean isPrefixOfAll = true;
for (String n : group) {
if (!n.toLowerCase().startsWith(shortest.toLowerCase())) {
isPrefixOfAll = false;
break;
}
}
if (isPrefixOfAll) {
return shortest;
}
shortest = shortest.substring(0, shortest.length() - 1);
}
return name;
}
private boolean hasCommonPrefix(String a, String b) {
// Dva názvy patří do stejné skupiny, pokud jeden začíná na druhý
// nebo mají společnou předponu alespoň 3 znaky
return a.toLowerCase().startsWith(b.toLowerCase())
|| b.toLowerCase().startsWith(a.toLowerCase())
|| getCommonPrefix(a, b).length() >= 3;
}
private String getCommonPrefix(String a, String b) {
int minLen = Math.min(a.length(), b.length());
int i = 0;
while (i < minLen && a.toLowerCase().charAt(i) == b.toLowerCase().charAt(i)) {
i++;
}
return a.substring(0, i);
}
private StatsResponse getStats(String machine, String process, String status, String from, String to) throws SQLException {
StringBuilder sql = new StringBuilder("SELECT id, machine_name, status, detected_at, process_name FROM process_heartbeat WHERE 1=1");
java.util.List<String> params = new java.util.ArrayList<>();
if (machine != null && !machine.isBlank()) {
sql.append(" AND machine_name = ?");
params.add(machine);
}
if (process != null && !process.isBlank()) {
sql.append(" AND process_name = ?");
params.add(process);
}
if (status != null && !status.isBlank()) {
sql.append(" AND status = ?");
params.add(status);
}
if (from != null && !from.isBlank()) {
sql.append(" AND detected_at >= ?");
params.add(from);
}
if (to != null && !to.isBlank()) {
sql.append(" AND detected_at <= ?");
params.add(to);
}
sql.append(" ORDER BY detected_at DESC LIMIT 10000");
try (Connection connection = DriverManager.getConnection(config.jdbcUrl, config.dbUser, config.dbPassword);
PreparedStatement stmt = connection.prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
stmt.setString(i + 1, params.get(i));
}
// Nejprve načíst všechny unikátní názvy procesů pro určení hlavního názvu
java.util.List<String> allProcessNames = new java.util.ArrayList<>();
try (PreparedStatement allStmt = connection.prepareStatement(
"SELECT DISTINCT process_name FROM process_heartbeat WHERE process_name IS NOT NULL")) {
try (java.sql.ResultSet rs = allStmt.executeQuery()) {
while (rs.next()) {
allProcessNames.add(rs.getString(1));
}
}
}
java.util.List<Record> records = new java.util.ArrayList<>();
try (java.sql.ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String processName = rs.getString(5);
String mainName = extractMainName(processName, allProcessNames);
records.add(new Record(
rs.getLong(1),
rs.getString(2),
rs.getString(3),
rs.getTimestamp(4),
processName,
mainName
));
}
}
return new StatsResponse(records);
}
}
private LastRecordTimeResponse getLastRecordTime(String date) throws SQLException {
String sql = "SELECT detected_at FROM process_heartbeat WHERE DATE(detected_at) = ? ORDER BY detected_at DESC LIMIT 1";
try (Connection connection = DriverManager.getConnection(config.jdbcUrl, config.dbUser, config.dbPassword);
PreparedStatement stmt = connection.prepareStatement(sql)) {
if (date != null && !date.isBlank()) {
stmt.setString(1, date);
} else {
stmt.setString(1, java.time.LocalDate.now(ZoneId.systemDefault()).toString());
}
try (java.sql.ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
Timestamp ts = rs.getTimestamp(1);
return new LastRecordTimeResponse(ts.toInstant().toString());
}
return new LastRecordTimeResponse(null);
}
}
}
}
private static final class FilterOptions {
List<String> machines;
List<String> processes;
List<String> statuses;
FilterOptions(List<String> machines, List<String> processes, List<String> statuses) {
this.machines = machines;
this.processes = processes;
this.statuses = statuses;
}
}
private static final class Record {
long id;
String machine_name;
String status;
String detected_at;
String process_name;
String main_name;
Record(long id, String machine_name, String status, Timestamp detected_at, String process_name, String main_name) {
this.id = id;
this.machine_name = machine_name;
this.status = status;
this.detected_at = detected_at != null ? detected_at.toInstant().toString() : null;
this.process_name = process_name;
this.main_name = main_name;
}
}
private static final class StatsResponse {
List<Record> records;
StatsResponse(List<Record> records) {
this.records = records;
}
}
private static final class LastRecordTimeResponse {
String lastRecordTime;
LastRecordTimeResponse(String lastRecordTime) {
this.lastRecordTime = lastRecordTime;
}
}
private static final class HeartbeatRequest {
private String machine_name;
private String status;
private String detected_at;
private List<String> processes;
private transient String machineName;
private transient Instant detectedAt;
private static HeartbeatRequest parse(String json) {
try {
HeartbeatRequest request = GSON.fromJson(json, HeartbeatRequest.class);
if (request == null) {
throw new IllegalArgumentException("Empty request body");
}
request.validate();
return request;
} catch (JsonSyntaxException ex) {
throw new IllegalArgumentException("Invalid JSON");
}
}
private void validate() {
machineName = required(machine_name, "machine_name");
status = required(status, "status");
try {
detectedAt = Instant.parse(required(detected_at, "detected_at"));
} catch (DateTimeParseException ex) {
throw new IllegalArgumentException("Invalid detected_at");
}
if (processes == null) {
processes = Collections.emptyList();
}
}
private static String required(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Missing field: " + fieldName);
}
return value.trim();
}
}
private static final class DashboardHandler implements HttpHandler {
private final AppConfig config;
private DashboardHandler(AppConfig config) {
this.config = config;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendHtml(exchange, 405, "<h1>Method Not Allowed</h1>");
return;
}
String query = exchange.getRequestURI().getQuery();
String apiKey = getParam(query, "apiKey");
if (apiKey == null || apiKey.isBlank()) {
String html = loadResource("login.html");
sendHtml(exchange, 200, html);
return;
}
if (!apiKey.equals(config.dashboardApiKey)) {
String html = loadResource("error.html").replace("%MESSAGE%", escapeHtml("Neplatný API klíč"));
sendHtml(exchange, 401, html);
return;
}
String html = loadResource("dashboard.html").replace("%API_KEY%", apiKey);
sendHtml(exchange, 200, html);
}
private static String getParam(String query, String name) {
if (query == null) return null;
String[] pairs = query.split("&");
for (String pair : pairs) {
String[] parts = pair.split("=", 2);
if (parts.length == 2 && parts[0].equals(name)) {
try {
return java.net.URLDecoder.decode(parts[1], StandardCharsets.UTF_8);
} catch (Exception ex) {
return null;
}
}
}
return null;
}
private String loadResource(String resourceName) throws IOException {
try (InputStream inputStream = Main.class.getClassLoader().getResourceAsStream(resourceName)) {
if (inputStream == null) {
throw new IOException("Resource not found: " + resourceName);
}
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private void sendHtml(HttpExchange exchange, int statusCode, String html) throws IOException {
byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(bytes);
}
}
}
private static final class DataApiHandler implements HttpHandler {
private final Database database;
private final AppConfig config;
private DataApiHandler(Database database, AppConfig config) {
this.database = database;
this.config = config;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendJson(exchange, 405, "{\"error\":\"method_not_allowed\"}");
return;
}
String query = exchange.getRequestURI().getQuery();
String apiKey = getParam(query, "apiKey");
if (apiKey == null || apiKey.isBlank() || !apiKey.equals(config.dashboardApiKey)) {
sendJson(exchange, 401, "{\"error\":\"unauthorized\"}");
return;
}
String type = getParam(query, "type");
if ("filters".equals(type)) {
String response = GSON.toJson(database.getFilterOptions());
sendJson(exchange, 200, response);
} else if ("lastRecordTime".equals(type)) {
String date = getParam(query, "date");
String response = GSON.toJson(database.getLastRecordTime(date));
sendJson(exchange, 200, response);
} else if ("stats".equals(type)) {
String machine = getParam(query, "machine");
String process = getParam(query, "process");
String status = getParam(query, "status");
String from = getParam(query, "from");
String to = getParam(query, "to");
String response = GSON.toJson(database.getStats(machine, process, status, from, to));
sendJson(exchange, 200, response);
} else {
sendJson(exchange, 400, "{\"error\":\"invalid_type\"}");
}
} catch (Exception ex) {
ex.printStackTrace();
sendJson(exchange, 500, "{\"error\":\"internal_error\"}");
}
}
private static String getParam(String query, String name) {
if (query == null) return null;
String[] pairs = query.split("&");
for (String pair : pairs) {
String[] parts = pair.split("=", 2);
if (parts.length == 2 && parts[0].equals(name)) {
try {
return java.net.URLDecoder.decode(parts[1], StandardCharsets.UTF_8);
} catch (Exception ex) {
return null;
}
}
}
return null;
}
private static void sendJson(HttpExchange exchange, int statusCode, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(bytes);
}
}
}
private static void log(String message) { private static void log(String message) {
System.out.printf("[%s] %s%n", Instant.now(), message); System.out.printf("[%s] %s%n", Instant.now(), message);
} }
private static String escapeJson(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static String escapeHtml(String value) {
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;");
}
} }

View File

@ -0,0 +1,59 @@
package cz.kamma.processmonitor.config;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
public class AppConfig {
private final String jdbcUrl;
private final String dbUser;
private final String dbPassword;
private final String dashboardApiKey;
public AppConfig(String jdbcUrl, String dbUser, String dbPassword, String dashboardApiKey) {
this.jdbcUrl = jdbcUrl;
this.dbUser = dbUser;
this.dbPassword = dbPassword;
this.dashboardApiKey = dashboardApiKey;
}
public String getJdbcUrl() {
return jdbcUrl;
}
public String getDbUser() {
return dbUser;
}
public String getDbPassword() {
return dbPassword;
}
public String getDashboardApiKey() {
return dashboardApiKey;
}
public static AppConfig load() throws IOException {
Properties properties = new Properties();
try (InputStream inputStream = AppConfig.class.getClassLoader().getResourceAsStream("application.properties")) {
if (inputStream == null) {
throw new IOException("Missing application.properties");
}
properties.load(inputStream);
}
String jdbcUrl = required(properties, "db.url");
String dbUser = required(properties, "db.user");
String dbPassword = required(properties, "db.password");
String dashboardApiKey = required(properties, "dashboard.apiKey");
return new AppConfig(jdbcUrl, dbUser, dbPassword, dashboardApiKey);
}
private static String required(Properties properties, String key) {
String value = properties.getProperty(key);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Missing config key: " + key);
}
return value.trim();
}
}

View File

@ -0,0 +1,90 @@
package cz.kamma.processmonitor.handler;
import com.google.gson.Gson;
import cz.kamma.processmonitor.config.AppConfig;
import cz.kamma.processmonitor.model.FilterOptions;
import cz.kamma.processmonitor.model.LastRecordTimeResponse;
import cz.kamma.processmonitor.model.Record;
import cz.kamma.processmonitor.model.StatsResponse;
import cz.kamma.processmonitor.repository.ProcessRepository;
import cz.kamma.processmonitor.service.ProcessService;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
public class DashboardHandler implements HttpHandler {
private final AppConfig config;
public DashboardHandler(AppConfig config) {
this.config = config;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendHtml(exchange, 405, "<h1>Method Not Allowed</h1>");
return;
}
String query = exchange.getRequestURI().getQuery();
String apiKey = getParam(query, "apiKey");
if (apiKey == null || apiKey.isBlank()) {
String html = loadResource("login.html");
sendHtml(exchange, 200, html);
return;
}
if (!apiKey.equals(config.getDashboardApiKey())) {
String html = loadResource("error.html").replace("%MESSAGE%", "Neplatný API klíč");
sendHtml(exchange, 401, html);
return;
}
String html = loadResource("dashboard.html").replace("%API_KEY%", apiKey);
sendHtml(exchange, 200, html);
}
private String loadResource(String resourceName) throws IOException {
try (java.io.InputStream inputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourceName)) {
if (inputStream == null) {
try (java.io.InputStream fallbackStream = getClass().getClassLoader().getResourceAsStream(resourceName)) {
if (fallbackStream == null) {
throw new IOException("Resource not found: " + resourceName);
}
return new String(fallbackStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private void sendHtml(HttpExchange exchange, int statusCode, String html) throws IOException {
byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(bytes);
}
}
private String getParam(String query, String name) {
if (query == null) return null;
String[] pairs = query.split("&");
for (String pair : pairs) {
String[] parts = pair.split("=", 2);
if (parts.length == 2 && parts[0].equals(name)) {
try {
return java.net.URLDecoder.decode(parts[1], StandardCharsets.UTF_8);
} catch (Exception ex) {
return null;
}
}
}
return null;
}
}

View File

@ -0,0 +1,107 @@
package cz.kamma.processmonitor.handler;
import com.google.gson.Gson;
import cz.kamma.processmonitor.config.AppConfig;
import cz.kamma.processmonitor.model.FilterOptions;
import cz.kamma.processmonitor.model.LastRecordTimeResponse;
import cz.kamma.processmonitor.model.Record;
import cz.kamma.processmonitor.model.StatsResponse;
import cz.kamma.processmonitor.repository.ProcessRepository;
import cz.kamma.processmonitor.service.ProcessService;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.List;
public class DataApiHandler implements HttpHandler {
private static final Gson GSON = new Gson();
private final AppConfig config;
private final ProcessRepository repository;
private final ProcessService processService;
public DataApiHandler(AppConfig config, ProcessRepository repository, ProcessService processService) {
this.config = config;
this.repository = repository;
this.processService = processService;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendJson(exchange, 405, "{\"error\":\"method_not_allowed\"}");
return;
}
String query = exchange.getRequestURI().getQuery();
String apiKey = getParam(query, "apiKey");
if (apiKey == null || apiKey.isBlank() || !apiKey.equals(config.getDashboardApiKey())) {
sendJson(exchange, 401, "{\"error\":\"unauthorized\"}");
return;
}
String type = getParam(query, "type");
if ("filters".equals(type)) {
String response = GSON.toJson(repository.getFilterOptions());
sendJson(exchange, 200, response);
} else if ("lastRecordTime".equals(type)) {
String date = getParam(query, "date");
String response = GSON.toJson(repository.getLastRecordTime(date));
sendJson(exchange, 200, response);
} else if ("stats".equals(type)) {
String machine = getParam(query, "machine");
String process = getParam(query, "process");
String status = getParam(query, "status");
String from = getParam(query, "from");
String to = getParam(query, "to");
StatsResponse stats = repository.getStats(machine, process, status, from, to);
List<String> allProcessNames = repository.getAllProcessNames();
for (Record record : stats.records) {
record.main_name = processService.extractMainName(record.process_name, allProcessNames);
}
String response = GSON.toJson(stats);
sendJson(exchange, 200, response);
} else {
sendJson(exchange, 400, "{\"error\":\"invalid_type\"}");
}
} catch (Exception ex) {
ex.printStackTrace();
sendJson(exchange, 500, "{\"error\":\"internal_error\"}");
}
}
private String getParam(String query, String name) {
if (query == null) return null;
String[] pairs = query.split("&");
for (String pair : pairs) {
String[] parts = pair.split("=", 2);
if (parts.length == 2 && parts[0].equals(name)) {
try {
return java.net.URLDecoder.decode(parts[1], StandardCharsets.UTF_8);
} catch (Exception ex) {
return null;
}
}
}
return null;
}
private void sendJson(HttpExchange exchange, int statusCode, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(bytes);
}
}
}

View File

@ -0,0 +1,79 @@
package cz.kamma.processmonitor.handler;
import com.google.gson.Gson;
import cz.kamma.processmonitor.config.AppConfig;
import cz.kamma.processmonitor.model.HeartbeatRequest;
import cz.kamma.processmonitor.repository.ProcessRepository;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.logging.Logger;
public class HeartbeatHandler implements HttpHandler {
private static final Logger LOGGER = Logger.getLogger(HeartbeatHandler.class.getName());
private final ProcessRepository repository;
public HeartbeatHandler(ProcessRepository repository) {
this.repository = repository;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
sendJson(exchange, 405, "{\"error\":\"method_not_allowed\"}");
return;
}
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
if (contentType == null || !contentType.toLowerCase().contains("application/json")) {
sendJson(exchange, 415, "{\"error\":\"unsupported_media_type\"}");
return;
}
String body = readBody(exchange.getRequestBody());
LOGGER.info("Incoming heartbeat request: " + body);
HeartbeatRequest request = HeartbeatRequest.parse(body);
int insertedRows = repository.saveHeartbeat(
request.getMachineName(),
request.getStatus(),
request.getDetectedAt(),
request.getProcesses()
);
LOGGER.info("Stored heartbeat for machine " + request.getMachineName() + ", inserted rows: " + insertedRows);
sendJson(exchange, 200, String.format("{\"ok\":true,\"inserted\":%d}", insertedRows));
} catch (IllegalArgumentException ex) {
LOGGER.warning("Request validation failed: " + ex.getMessage());
sendJson(exchange, 400, "{\"error\":\"" + escapeJson(ex.getMessage()) + "\"}");
} catch (Exception ex) {
ex.printStackTrace();
LOGGER.severe("Internal server error: " + ex.getMessage());
sendJson(exchange, 500, "{\"error\":\"internal_error\"}");
}
}
private String readBody(InputStream inputStream) throws IOException {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
private void sendJson(HttpExchange exchange, int statusCode, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(bytes);
}
}
private String escapeJson(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
}

View File

@ -0,0 +1,15 @@
package cz.kamma.processmonitor.model;
import java.util.List;
public class FilterOptions {
public List<String> machines;
public List<String> processes;
public List<String> statuses;
public FilterOptions(List<String> machines, List<String> processes, List<String> statuses) {
this.machines = machines;
this.processes = processes;
this.statuses = statuses;
}
}

View File

@ -0,0 +1,101 @@
package cz.kamma.processmonitor.model;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.List;
public class HeartbeatRequest {
private String machine_name;
private String status;
private String detected_at;
private List<String> processes;
private transient String machineName;
private transient Instant detectedAt;
private static final Gson GSON = new Gson();
public String getMachine_name() {
return machine_name;
}
public void setMachine_name(String machine_name) {
this.machine_name = machine_name;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getDetected_at() {
return detected_at;
}
public void setDetected_at(String detected_at) {
this.detected_at = detected_at;
}
public List<String> getProcesses() {
return processes;
}
public void setProcesses(List<String> processes) {
this.processes = processes;
}
public String getMachineName() {
return machineName;
}
public void setMachineName(String machineName) {
this.machineName = machineName;
}
public Instant getDetectedAt() {
return detectedAt;
}
public void setDetectedAt(Instant detectedAt) {
this.detectedAt = detectedAt;
}
public static HeartbeatRequest parse(String json) {
try {
HeartbeatRequest request = GSON.fromJson(json, HeartbeatRequest.class);
if (request == null) {
throw new IllegalArgumentException("Empty request body");
}
request.validate();
return request;
} catch (JsonSyntaxException ex) {
throw new IllegalArgumentException("Invalid JSON");
}
}
public void validate() {
machineName = required(machine_name, "machine_name");
status = required(status, "status");
try {
detectedAt = Instant.parse(required(detected_at, "detected_at"));
} catch (DateTimeParseException ex) {
throw new IllegalArgumentException("Invalid detected_at");
}
if (processes == null) {
processes = Collections.emptyList();
}
}
private String required(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Missing field: " + fieldName);
}
return value.trim();
}
}

View File

@ -0,0 +1,9 @@
package cz.kamma.processmonitor.model;
public class LastRecordTimeResponse {
public String lastRecordTime;
public LastRecordTimeResponse(String lastRecordTime) {
this.lastRecordTime = lastRecordTime;
}
}

View File

@ -0,0 +1,21 @@
package cz.kamma.processmonitor.model;
import java.sql.Timestamp;
public class Record {
public long id;
public String machine_name;
public String status;
public String detected_at;
public String process_name;
public String main_name;
public Record(long id, String machine_name, String status, Timestamp detected_at, String process_name, String main_name) {
this.id = id;
this.machine_name = machine_name;
this.status = status;
this.detected_at = detected_at != null ? detected_at.toInstant().toString() : null;
this.process_name = process_name;
this.main_name = main_name;
}
}

View File

@ -0,0 +1,11 @@
package cz.kamma.processmonitor.model;
import java.util.List;
public class StatsResponse {
public List<Record> records;
public StatsResponse(List<Record> records) {
this.records = records;
}
}

View File

@ -0,0 +1,170 @@
package cz.kamma.processmonitor.repository;
import cz.kamma.processmonitor.config.AppConfig;
import cz.kamma.processmonitor.model.FilterOptions;
import cz.kamma.processmonitor.model.LastRecordTimeResponse;
import cz.kamma.processmonitor.model.Record;
import cz.kamma.processmonitor.model.StatsResponse;
import java.sql.*;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class ProcessRepository {
private final AppConfig config;
public ProcessRepository(AppConfig config) {
this.config = config;
}
private Connection getConnection() throws SQLException {
return DriverManager.getConnection(config.getJdbcUrl(), config.getDbUser(), config.getDbPassword());
}
public int saveHeartbeat(String machineName, String status, Instant detectedAt, List<String> processes) throws SQLException {
List<String> processList = processes.isEmpty() ? Collections.singletonList(null) : processes;
String sql = "INSERT INTO process_heartbeat (machine_name, status, detected_at, process_name) VALUES (?, ?, ?, ?)";
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
int inserted = 0;
Timestamp timestamp = Timestamp.from(detectedAt.atZone(ZoneId.systemDefault()).toInstant());
for (String processName : processList) {
statement.setString(1, machineName);
statement.setString(2, status);
statement.setTimestamp(3, timestamp);
statement.setString(4, processName);
inserted += statement.executeUpdate();
}
return inserted;
}
}
public FilterOptions getFilterOptions() throws SQLException {
String sqlMachines = "SELECT DISTINCT machine_name FROM process_heartbeat ORDER BY machine_name";
String sqlProcesses = "SELECT DISTINCT process_name FROM process_heartbeat WHERE process_name IS NOT NULL ORDER BY process_name";
String sqlStatuses = "SELECT DISTINCT status FROM process_heartbeat ORDER BY status";
try (Connection connection = getConnection()) {
List<String> machines = new ArrayList<>();
try (PreparedStatement stmt = connection.prepareStatement(sqlMachines);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
machines.add(rs.getString(1));
}
}
List<String> processes = new ArrayList<>();
try (PreparedStatement stmt = connection.prepareStatement(sqlProcesses);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
processes.add(rs.getString(1));
}
}
List<String> statuses = new ArrayList<>();
try (PreparedStatement stmt = connection.prepareStatement(sqlStatuses);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
statuses.add(rs.getString(1));
}
}
return new FilterOptions(machines, processes, statuses);
}
}
public List<String> getAllProcessNames() throws SQLException {
String sql = "SELECT DISTINCT process_name FROM process_heartbeat WHERE process_name IS NOT NULL";
List<String> processNames = new ArrayList<>();
try (Connection connection = getConnection();
PreparedStatement stmt = connection.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
processNames.add(rs.getString(1));
}
}
return processNames;
}
public StatsResponse getStats(String machine, String process, String status, String from, String to) throws SQLException {
StringBuilder sql = new StringBuilder("SELECT id, machine_name, status, detected_at, process_name FROM process_heartbeat WHERE 1=1");
List<String> params = new ArrayList<>();
if (machine != null && !machine.isBlank()) {
sql.append(" AND machine_name = ?");
params.add(machine);
}
if (process != null && !process.isBlank()) {
sql.append(" AND process_name = ?");
params.add(process);
}
if (status != null && !status.isBlank()) {
sql.append(" AND status = ?");
params.add(status);
}
if (from != null && !from.isBlank()) {
sql.append(" AND detected_at >= ?");
params.add(from);
}
if (to != null && !to.isBlank()) {
sql.append(" AND detected_at <= ?");
params.add(to);
}
sql.append(" ORDER BY detected_at DESC LIMIT 10000");
try (Connection connection = getConnection();
PreparedStatement stmt = connection.prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
stmt.setString(i + 1, params.get(i));
}
List<Record> records = new ArrayList<>();
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
long id = rs.getLong(1);
String mName = rs.getString(2);
String st = rs.getString(3);
Timestamp dt = rs.getTimestamp(4);
String pName = rs.getString(5);
// We will fill main_name in the service layer or here
// To keep repository clean, we'll return the raw data first
// But the current design expects main_name in Record.
// I'll add a placeholder and let the service layer fill it if needed,
// or just return the raw fields.
// Actually, let's just use a temporary way to store it.
records.add(new Record(id, mName, st, dt, pName, null));
}
}
return new StatsResponse(records);
}
}
public LastRecordTimeResponse getLastRecordTime(String date) throws SQLException {
String sql = "SELECT detected_at FROM process_heartbeat WHERE DATE(detected_at) = ? ORDER BY detected_at DESC LIMIT 1";
try (Connection connection = getConnection();
PreparedStatement stmt = connection.prepareStatement(sql)) {
if (date != null && !date.isBlank()) {
stmt.setString(1, date);
} else {
stmt.setString(1, java.time.LocalDate.now(ZoneId.systemDefault()).toString());
}
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
Timestamp ts = rs.getTimestamp(1);
return new LastRecordTimeResponse(ts.toInstant().toString());
}
return new LastRecordTimeResponse(null);
}
}
}
}

View File

@ -0,0 +1,77 @@
package cz.kamma.processmonitor.service;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
public class ProcessService {
public String extractMainName(String processName, List<String> allProcessNames) {
if (processName == null || processName.isBlank()) {
return null;
}
String name = processName;
int dotIndex = name.lastIndexOf('.');
if (dotIndex > 0) {
name = name.substring(0, dotIndex);
}
Set<String> uniqueNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
for (String procName : allProcessNames) {
if (procName == null || procName.isBlank()) continue;
int dotIdx = procName.lastIndexOf('.');
if (dotIdx > 0) {
uniqueNames.add(procName.substring(0, dotIdx));
} else {
uniqueNames.add(procName);
}
}
List<String> group = new ArrayList<>();
for (String n : uniqueNames) {
if (hasCommonPrefix(name, n)) {
group.add(n);
}
}
if (group.size() == 1) {
return group.get(0);
}
group.sort((a, b) -> Integer.compare(a.length(), b.length()));
String shortest = group.get(0);
while (shortest.length() > 0) {
boolean isPrefixOfAll = true;
for (String n : group) {
if (!n.toLowerCase().startsWith(shortest.toLowerCase())) {
isPrefixOfAll = false;
break;
}
}
if (isPrefixOfAll) {
return shortest;
}
shortest = shortest.substring(0, shortest.length() - 1);
}
return name;
}
private boolean hasCommonPrefix(String a, String b) {
return a.toLowerCase().startsWith(b.toLowerCase())
|| b.toLowerCase().startsWith(a.toLowerCase())
|| getCommonPrefix(a, b).length() >= 3;
}
private String getCommonPrefix(String a, String b) {
int minLen = Math.min(a.length(), b.length());
int i = 0;
while (i < minLen && a.toLowerCase().charAt(i) == b.toLowerCase().charAt(i)) {
i++;
}
return a.substring(0, i);
}
}

View File

@ -330,29 +330,27 @@
loadLastRecordTime(); loadLastRecordTime();
const records = data.records; const records = data.records;
// Agregace času podle procesů // Agregace času podle procesů: pro každý hlavní název se zobrazí čas nejdelšího podprocesu
const processTimeMap = {}; const processCounts = {};
records.forEach(r => {
// Seřadit záznamy podle procesu a času const pName = r.process_name || '(bez procesu)';
const sortedRecords = records.sort((a, b) => processCounts[pName] = (processCounts[pName] || 0) + 1;
new Date(a.detected_at) - new Date(b.detected_at)
);
// Seskupit podle hlavního názvu a spočítat časy
const processByName = {};
sortedRecords.forEach(r => {
const mainName = r.main_name || r.process_name || '(bez procesu)';
if (!processByName[mainName]) {
processByName[mainName] = [];
}
processByName[mainName].push(r);
}); });
// Spočítat čas běhu pro každý hlavní název (45s za každý záznam) const mainNameToMaxCount = {};
Object.keys(processByName).forEach(mainName => { records.forEach(r => {
const records = processByName[mainName]; const mainName = r.main_name || r.process_name || '(bez procesu)';
const totalSeconds = records.length * 45; const pName = r.process_name || '(bez procesu)';
processTimeMap[mainName] = totalSeconds / 60; const count = processCounts[pName];
if (!mainNameToMaxCount[mainName] || count > mainNameToMaxCount[mainName]) {
mainNameToMaxCount[mainName] = count;
}
});
const processTimeMap = {};
Object.keys(mainNameToMaxCount).forEach(mainName => {
const maxCount = mainNameToMaxCount[mainName];
processTimeMap[mainName] = (maxCount * 45) / 60;
}); });
if (processTimeChart) processTimeChart.destroy(); if (processTimeChart) processTimeChart.destroy();
@ -362,9 +360,13 @@
const labels = Object.keys(processTimeMap).map(mainName => { const labels = Object.keys(processTimeMap).map(mainName => {
const minutes = processTimeMap[mainName]; const minutes = processTimeMap[mainName];
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const mins = minutes % 60; if (hours > 0) {
const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; const mins = Math.floor(minutes % 60);
return `${mainName} (${timeStr})`; return `${mainName} (${hours}h ${mins}m)`;
} else {
const mins = parseFloat(minutes.toFixed(1));
return `${mainName} (${mins}m)`;
}
}); });
processTimeChart = new Chart(processTimeCtx, { processTimeChart = new Chart(processTimeCtx, {