From 7d8c9be9e65e006a7f4b486c1c102a175a1847fc Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Sun, 5 Apr 2026 21:34:40 +0200 Subject: [PATCH] full refactor --- .../java/cz/kamma/processmonitor/Main.java | 641 ++---------------- .../processmonitor/config/AppConfig.java | 59 ++ .../handler/DashboardHandler.java | 90 +++ .../handler/DataApiHandler.java | 107 +++ .../handler/HeartbeatHandler.java | 79 +++ .../processmonitor/model/FilterOptions.java | 15 + .../model/HeartbeatRequest.java | 101 +++ .../model/LastRecordTimeResponse.java | 9 + .../cz/kamma/processmonitor/model/Record.java | 21 + .../processmonitor/model/StatsResponse.java | 11 + .../repository/ProcessRepository.java | 170 +++++ .../service/ProcessService.java | 77 +++ java-api/src/main/resources/dashboard.html | 50 +- 13 files changed, 803 insertions(+), 627 deletions(-) create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/config/AppConfig.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/handler/DashboardHandler.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/handler/DataApiHandler.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/handler/HeartbeatHandler.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/model/FilterOptions.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/model/HeartbeatRequest.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/model/LastRecordTimeResponse.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/model/Record.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/model/StatsResponse.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/repository/ProcessRepository.java create mode 100644 java-api/src/main/java/cz/kamma/processmonitor/service/ProcessService.java diff --git a/java-api/src/main/java/cz/kamma/processmonitor/Main.java b/java-api/src/main/java/cz/kamma/processmonitor/Main.java index 4b322e8..90673ca 100644 --- a/java-api/src/main/java/cz/kamma/processmonitor/Main.java +++ b/java-api/src/main/java/cz/kamma/processmonitor/Main.java @@ -1,603 +1,38 @@ -package cz.kamma.processmonitor; - -import com.google.gson.Gson; -import com.google.gson.JsonSyntaxException; -import com.sun.net.httpserver.Headers; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -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.ZoneId; -import java.time.format.DateTimeParseException; -import java.util.Collections; -import java.util.List; -import java.util.Properties; - -public class Main { - private static final Gson GSON = new Gson(); - - public static void main(String[] args) throws Exception { - AppConfig config = AppConfig.load(); - Database database = new Database(config); - HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", 8080), 0); - server.createContext("/hb/api", new HeartbeatHandler(database)); - server.createContext("/hb/dashboard", new DashboardHandler(config)); - server.createContext("/hb/api/data", new DataApiHandler(database, config)); - server.setExecutor(null); - server.start(); - - log("Listening on http://127.0.0.1:8080"); - log("Dashboard: http://127.0.0.1:8080/hb/dashboard"); - 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 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 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 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 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 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 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 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 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 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 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 machines; - List processes; - List statuses; - - FilterOptions(List machines, List processes, List 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 records; - - StatsResponse(List 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 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, "

Method Not Allowed

"); - 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) { - 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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"); - } -} +package cz.kamma.processmonitor; + +import cz.kamma.processmonitor.config.AppConfig; +import cz.kamma.processmonitor.handler.DashboardHandler; +import cz.kamma.processmonitor.handler.DataApiHandler; +import cz.kamma.processmonitor.handler.HeartbeatHandler; +import cz.kamma.processmonitor.repository.ProcessRepository; +import cz.kamma.processmonitor.service.ProcessService; +import com.sun.net.httpserver.HttpServer; + +import java.net.InetSocketAddress; +import java.time.Instant; + +public class Main { + + public static void main(String[] args) throws Exception { + AppConfig config = AppConfig.load(); + ProcessRepository repository = new ProcessRepository(config); + ProcessService processService = new ProcessService(); + + HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", 8080), 0); + + server.createContext("/hb/api", new HeartbeatHandler(repository)); + server.createContext("/hb/dashboard", new DashboardHandler(config)); + server.createContext("/hb/api/data", new DataApiHandler(config, repository, processService)); + + server.setExecutor(null); + server.start(); + + log("Listening on http://127.0.0.1:8080"); + log("Dashboard: http://127.0.0.1:8080/hb/dashboard"); + log("API endpoint: http://127.0.0.1:8080/hb/api"); + } + + private static void log(String message) { + System.out.printf("[%s] %s%n", Instant.now(), message); + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/config/AppConfig.java b/java-api/src/main/java/cz/kamma/processmonitor/config/AppConfig.java new file mode 100644 index 0000000..78ea30f --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/config/AppConfig.java @@ -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(); + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/handler/DashboardHandler.java b/java-api/src/main/java/cz/kamma/processmonitor/handler/DashboardHandler.java new file mode 100644 index 0000000..cdf7571 --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/handler/DashboardHandler.java @@ -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, "

Method Not Allowed

"); + 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; + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/handler/DataApiHandler.java b/java-api/src/main/java/cz/kamma/processmonitor/handler/DataApiHandler.java new file mode 100644 index 0000000..31814df --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/handler/DataApiHandler.java @@ -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 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); + } + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/handler/HeartbeatHandler.java b/java-api/src/main/java/cz/kamma/processmonitor/handler/HeartbeatHandler.java new file mode 100644 index 0000000..7478d89 --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/handler/HeartbeatHandler.java @@ -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("\"", "\\\""); + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/model/FilterOptions.java b/java-api/src/main/java/cz/kamma/processmonitor/model/FilterOptions.java new file mode 100644 index 0000000..b1d371d --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/model/FilterOptions.java @@ -0,0 +1,15 @@ +package cz.kamma.processmonitor.model; + +import java.util.List; + +public class FilterOptions { + public List machines; + public List processes; + public List statuses; + + public FilterOptions(List machines, List processes, List statuses) { + this.machines = machines; + this.processes = processes; + this.statuses = statuses; + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/model/HeartbeatRequest.java b/java-api/src/main/java/cz/kamma/processmonitor/model/HeartbeatRequest.java new file mode 100644 index 0000000..57de94c --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/model/HeartbeatRequest.java @@ -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 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 getProcesses() { + return processes; + } + + public void setProcesses(List 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(); + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/model/LastRecordTimeResponse.java b/java-api/src/main/java/cz/kamma/processmonitor/model/LastRecordTimeResponse.java new file mode 100644 index 0000000..25939c1 --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/model/LastRecordTimeResponse.java @@ -0,0 +1,9 @@ +package cz.kamma.processmonitor.model; + +public class LastRecordTimeResponse { + public String lastRecordTime; + + public LastRecordTimeResponse(String lastRecordTime) { + this.lastRecordTime = lastRecordTime; + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/model/Record.java b/java-api/src/main/java/cz/kamma/processmonitor/model/Record.java new file mode 100644 index 0000000..391e084 --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/model/Record.java @@ -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; + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/model/StatsResponse.java b/java-api/src/main/java/cz/kamma/processmonitor/model/StatsResponse.java new file mode 100644 index 0000000..cefc762 --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/model/StatsResponse.java @@ -0,0 +1,11 @@ +package cz.kamma.processmonitor.model; + +import java.util.List; + +public class StatsResponse { + public List records; + + public StatsResponse(List records) { + this.records = records; + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/repository/ProcessRepository.java b/java-api/src/main/java/cz/kamma/processmonitor/repository/ProcessRepository.java new file mode 100644 index 0000000..0755064 --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/repository/ProcessRepository.java @@ -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 processes) throws SQLException { + List 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 machines = new ArrayList<>(); + try (PreparedStatement stmt = connection.prepareStatement(sqlMachines); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + machines.add(rs.getString(1)); + } + } + + List processes = new ArrayList<>(); + try (PreparedStatement stmt = connection.prepareStatement(sqlProcesses); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + processes.add(rs.getString(1)); + } + } + + List 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 getAllProcessNames() throws SQLException { + String sql = "SELECT DISTINCT process_name FROM process_heartbeat WHERE process_name IS NOT NULL"; + List 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 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 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); + } + } + } +} diff --git a/java-api/src/main/java/cz/kamma/processmonitor/service/ProcessService.java b/java-api/src/main/java/cz/kamma/processmonitor/service/ProcessService.java new file mode 100644 index 0000000..e3b2866 --- /dev/null +++ b/java-api/src/main/java/cz/kamma/processmonitor/service/ProcessService.java @@ -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 allProcessNames) { + if (processName == null || processName.isBlank()) { + return null; + } + + String name = processName; + int dotIndex = name.lastIndexOf('.'); + if (dotIndex > 0) { + name = name.substring(0, dotIndex); + } + + Set 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 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); + } +} diff --git a/java-api/src/main/resources/dashboard.html b/java-api/src/main/resources/dashboard.html index 1738119..bcbad0f 100644 --- a/java-api/src/main/resources/dashboard.html +++ b/java-api/src/main/resources/dashboard.html @@ -330,29 +330,27 @@ loadLastRecordTime(); const records = data.records; - // Agregace času podle procesů - const processTimeMap = {}; - - // Seřadit záznamy podle procesu a času - const sortedRecords = records.sort((a, b) => - 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); + // Agregace času podle procesů: pro každý hlavní název se zobrazí čas nejdelšího podprocesu + const processCounts = {}; + records.forEach(r => { + const pName = r.process_name || '(bez procesu)'; + processCounts[pName] = (processCounts[pName] || 0) + 1; }); - // Spočítat čas běhu pro každý hlavní název (45s za každý záznam) - Object.keys(processByName).forEach(mainName => { - const records = processByName[mainName]; - const totalSeconds = records.length * 45; - processTimeMap[mainName] = totalSeconds / 60; + const mainNameToMaxCount = {}; + records.forEach(r => { + const mainName = r.main_name || r.process_name || '(bez procesu)'; + const pName = r.process_name || '(bez procesu)'; + 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(); @@ -362,9 +360,13 @@ const labels = Object.keys(processTimeMap).map(mainName => { const minutes = processTimeMap[mainName]; const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; - return `${mainName} (${timeStr})`; + if (hours > 0) { + const mins = Math.floor(minutes % 60); + return `${mainName} (${hours}h ${mins}m)`; + } else { + const mins = parseFloat(minutes.toFixed(1)); + return `${mainName} (${mins}m)`; + } }); processTimeChart = new Chart(processTimeCtx, {