diff --git a/argus-cli/src/main/java/io/argus/cli/ArgusCli.java b/argus-cli/src/main/java/io/argus/cli/ArgusCli.java index 2c69121..91e14b2 100644 --- a/argus-cli/src/main/java/io/argus/cli/ArgusCli.java +++ b/argus-cli/src/main/java/io/argus/cli/ArgusCli.java @@ -2,6 +2,7 @@ import io.argus.cli.command.Command; import io.argus.cli.command.CommandExitException; +import io.argus.cli.command.ClusterCommand; import io.argus.cli.command.BuffersCommand; import io.argus.cli.command.CiCommand; import io.argus.cli.command.ClassStatCommand; @@ -153,6 +154,7 @@ public static void main(String[] args) { // Register all commands Map commands = new LinkedHashMap<>(); + register(commands, new ClusterCommand()); register(commands, new InitCommand()); register(commands, new PsCommand()); register(commands, new HistoCommand()); diff --git a/argus-cli/src/main/java/io/argus/cli/cluster/ClusterHealthAggregator.java b/argus-cli/src/main/java/io/argus/cli/cluster/ClusterHealthAggregator.java new file mode 100644 index 0000000..301f1c3 --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/cluster/ClusterHealthAggregator.java @@ -0,0 +1,142 @@ +package io.argus.cli.cluster; + +import java.util.List; + +/** + * Aggregates health metrics across multiple JVM instances. + */ +public final class ClusterHealthAggregator { + + /** Known Prometheus metric name candidates for each dimension. */ + private static final String[] HEAP_METRICS = { + "argus_heap_used_percent", + "jvm_memory_used_bytes", + "heap_used_percent" + }; + private static final String[] GC_METRICS = { + "argus_gc_overhead_percent", + "jvm_gc_overhead_percent", + "gc_overhead_percent" + }; + private static final String[] CPU_METRICS = { + "argus_cpu_process_percent", + "jvm_cpu_usage", + "process_cpu_usage" + }; + private static final String[] VT_METRICS = { + "argus_virtual_threads_active", + "jvm_virtual_threads_active", + "virtual_threads_active" + }; + private static final String[] LEAK_METRICS = { + "argus_memory_leak_suspected", + "memory_leak_suspected" + }; + + private ClusterHealthAggregator() {} + + public record InstanceMetrics( + String target, + double heapPercent, + double gcOverhead, + double cpuPercent, + boolean leakSuspected, + long activeVThreads, + boolean reachable + ) {} + + public record AggregateStats( + double heapMin, double heapMax, double heapAvg, + double gcMin, double gcMax, double gcAvg, + double cpuMin, double cpuMax, double cpuAvg, + long vtTotal, + int leakCount, + String worstTarget, + String worstReason + ) {} + + /** + * Extracts per-instance metrics from the raw Prometheus map. + */ + public static InstanceMetrics extract(String target, java.util.Map raw) { + double heap = pick(raw, HEAP_METRICS, -1.0); + double gc = pick(raw, GC_METRICS, -1.0); + double cpu = pick(raw, CPU_METRICS, -1.0); + double vt = pick(raw, VT_METRICS, 0.0); + double leak = pick(raw, LEAK_METRICS, 0.0); + return new InstanceMetrics(target, heap, gc, cpu, leak > 0.5, (long) vt, true); + } + + /** + * Computes aggregate statistics from a list of reachable instance metrics. + */ + public static AggregateStats aggregate(List instances) { + List up = instances.stream().filter(InstanceMetrics::reachable).toList(); + if (up.isEmpty()) { + return new AggregateStats(-1,-1,-1,-1,-1,-1,-1,-1,-1,0,0,null,"no reachable instances"); + } + + double heapMin = Double.MAX_VALUE, heapMax = -1, heapSum = 0; + double gcMin = Double.MAX_VALUE, gcMax = -1, gcSum = 0; + double cpuMin = Double.MAX_VALUE, cpuMax = -1, cpuSum = 0; + long vtTotal = 0; + int leakCount = 0; + int heapCnt = 0, gcCnt = 0, cpuCnt = 0; + + for (InstanceMetrics m : up) { + if (m.heapPercent() >= 0) { + heapMin = Math.min(heapMin, m.heapPercent()); + heapMax = Math.max(heapMax, m.heapPercent()); + heapSum += m.heapPercent(); + heapCnt++; + } + if (m.gcOverhead() >= 0) { + gcMin = Math.min(gcMin, m.gcOverhead()); + gcMax = Math.max(gcMax, m.gcOverhead()); + gcSum += m.gcOverhead(); + gcCnt++; + } + if (m.cpuPercent() >= 0) { + cpuMin = Math.min(cpuMin, m.cpuPercent()); + cpuMax = Math.max(cpuMax, m.cpuPercent()); + cpuSum += m.cpuPercent(); + cpuCnt++; + } + vtTotal += m.activeVThreads(); + if (m.leakSuspected()) leakCount++; + } + + // Worst instance: highest GC overhead, then heap + InstanceMetrics worst = up.stream() + .filter(m -> m.gcOverhead() >= 0) + .max(java.util.Comparator.comparingDouble(InstanceMetrics::gcOverhead)) + .or(() -> up.stream() + .filter(m -> m.heapPercent() >= 0) + .max(java.util.Comparator.comparingDouble(InstanceMetrics::heapPercent))) + .orElse(up.get(0)); + + StringBuilder reason = new StringBuilder(); + if (worst.gcOverhead() >= 0) { + reason.append(String.format("GC overhead %.1f%%", worst.gcOverhead())); + } + if (worst.leakSuspected()) { + if (!reason.isEmpty()) reason.append(", "); + reason.append("memory leak suspected"); + } + + return new AggregateStats( + heapCnt > 0 ? heapMin : -1, heapCnt > 0 ? heapMax : -1, heapCnt > 0 ? heapSum / heapCnt : -1, + gcCnt > 0 ? gcMin : -1, gcCnt > 0 ? gcMax : -1, gcCnt > 0 ? gcSum / gcCnt : -1, + cpuCnt > 0 ? cpuMin : -1, cpuCnt > 0 ? cpuMax : -1, cpuCnt > 0 ? cpuSum / cpuCnt : -1, + vtTotal, leakCount, worst.target(), reason.toString() + ); + } + + private static double pick(java.util.Map map, String[] keys, double def) { + for (String key : keys) { + Double v = map.get(key); + if (v != null) return v; + } + return def; + } +} diff --git a/argus-cli/src/main/java/io/argus/cli/cluster/PrometheusTextParser.java b/argus-cli/src/main/java/io/argus/cli/cluster/PrometheusTextParser.java new file mode 100644 index 0000000..0cea7d2 --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/cluster/PrometheusTextParser.java @@ -0,0 +1,84 @@ +package io.argus.cli.cluster; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parses Prometheus exposition text format into a map of metric name to value. + * Skips comment lines (#) and handles optional labels in braces. + */ +public final class PrometheusTextParser { + + private PrometheusTextParser() {} + + /** + * Parses Prometheus text format. + * For metrics with labels (e.g. {@code metric{label="v"} 1.0}), the base metric name is used as the key. + * When multiple samples share the same base name, the last value wins. + * + * @param text raw Prometheus exposition text + * @return map of metric name to value + */ + public static Map parse(String text) { + Map metrics = new HashMap<>(); + if (text == null || text.isEmpty()) { + return metrics; + } + for (String rawLine : text.split("\n")) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + // Split off the value (last whitespace-delimited token) + // Prometheus line: metric_name{labels} value [timestamp] + // or: metric_name value [timestamp] + int lastSpace = line.lastIndexOf(' '); + if (lastSpace < 0) continue; + // Check for optional timestamp (third token) + String valueToken = line.substring(lastSpace + 1).trim(); + String rest = line.substring(0, lastSpace).trim(); + + // If rest still has a space the value token might be a timestamp; strip it + int secondSpace = rest.lastIndexOf(' '); + if (secondSpace >= 0) { + // rest = "metric{...} value", valueToken was timestamp — re-parse + String candidate = rest.substring(secondSpace + 1).trim(); + if (isNumeric(candidate)) { + valueToken = candidate; + rest = rest.substring(0, secondSpace).trim(); + } + } + + if (!isNumeric(valueToken)) continue; + double value; + try { + value = Double.parseDouble(valueToken); + } catch (NumberFormatException e) { + continue; + } + + // Extract base metric name (strip labels block) + String metricName; + int braceOpen = rest.indexOf('{'); + if (braceOpen >= 0) { + metricName = rest.substring(0, braceOpen).trim(); + } else { + metricName = rest.trim(); + } + if (!metricName.isEmpty()) { + metrics.put(metricName, value); + } + } + return metrics; + } + + private static boolean isNumeric(String s) { + if (s == null || s.isEmpty()) return false; + try { + Double.parseDouble(s); + return true; + } catch (NumberFormatException e) { + return false; + } + } +} diff --git a/argus-cli/src/main/java/io/argus/cli/command/ClusterCommand.java b/argus-cli/src/main/java/io/argus/cli/command/ClusterCommand.java new file mode 100644 index 0000000..08ebfca --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/command/ClusterCommand.java @@ -0,0 +1,310 @@ +package io.argus.cli.command; + +import io.argus.cli.cluster.ClusterHealthAggregator; +import io.argus.cli.cluster.ClusterHealthAggregator.AggregateStats; +import io.argus.cli.cluster.ClusterHealthAggregator.InstanceMetrics; +import io.argus.cli.cluster.PrometheusTextParser; +import io.argus.cli.config.CliConfig; +import io.argus.cli.config.Messages; +import io.argus.cli.provider.ProviderRegistry; +import io.argus.cli.render.AnsiStyle; +import io.argus.cli.render.RichRenderer; +import io.argus.core.command.CommandGroup; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +/** + * Discovers multiple Argus-enabled JVM instances and shows aggregated health metrics. + * + *

Usage: + *

+ *   argus cluster scan localhost:9202 localhost:9203
+ *   argus cluster scan --file=targets.txt
+ *   argus cluster health localhost:9202 localhost:9203
+ * 
+ */ +public final class ClusterCommand implements Command { + + private static final int WIDTH = RichRenderer.DEFAULT_WIDTH; + + @Override + public String name() { return "cluster"; } + + @Override + public CommandGroup group() { return CommandGroup.MONITORING; } + + @Override + public CommandMode mode() { return CommandMode.READ; } + + @Override + public boolean supportsTui() { return true; } + + @Override + public String description(Messages messages) { + return messages.get("cmd.cluster.desc"); + } + + @Override + public void execute(String[] args, CliConfig config, ProviderRegistry registry, Messages messages) { + if (args.length == 0) { + printHelp(); + return; + } + + String subCommand = args[0]; + if (!subCommand.equals("scan") && !subCommand.equals("health")) { + System.err.println("Unknown subcommand: " + subCommand); + printHelp(); + return; + } + + boolean json = "json".equals(config.format()); + boolean useColor = config.color(); + String file = null; + List targets = new ArrayList<>(); + + for (int i = 1; i < args.length; i++) { + String arg = args[i]; + if (arg.startsWith("--file=")) { + file = arg.substring(7); + } else if (arg.equals("--format=json")) { + json = true; + } else if (!arg.startsWith("--")) { + targets.add(arg); + } + } + + if (file != null) { + targets.addAll(readTargetsFile(file)); + } + + if (targets.isEmpty()) { + System.err.println(messages.get("error.cluster.no.targets")); + return; + } + + List results = fetchAll(targets); + AggregateStats stats = ClusterHealthAggregator.aggregate(results); + + if (json) { + printJson(results, stats); + } else { + printTable(useColor, results, stats, messages); + } + } + + // ── Fetching ──────────────────────────────────────────────────────────── + + private List fetchAll(List targets) { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + + List> futures = targets.stream() + .map(target -> fetchOne(client, target)) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + List results = new ArrayList<>(); + for (CompletableFuture f : futures) { + try { + results.add(f.get()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + // Should not happen — fetchOne catches all exceptions internally + } + } + return results; + } + + private CompletableFuture fetchOne(HttpClient client, String target) { + return CompletableFuture.supplyAsync(() -> { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://" + target + "/prometheus")) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + Map metrics = PrometheusTextParser.parse(response.body()); + return ClusterHealthAggregator.extract(target, metrics); + } + // Non-200 response: treat as unreachable + return downInstance(target); + } catch (Exception e) { + return downInstance(target); + } + }); + } + + private static InstanceMetrics downInstance(String target) { + return new InstanceMetrics(target, -1, -1, -1, false, 0, false); + } + + // ── Output: table ─────────────────────────────────────────────────────── + + private void printTable(boolean useColor, List instances, + AggregateStats stats, Messages messages) { + String title = messages.get("header.cluster"); + System.out.println(RichRenderer.boxHeader(useColor, title, WIDTH, + instances.size() + " instances")); + System.out.println(RichRenderer.emptyLine(WIDTH)); + + // Column header + String hdr = String.format(" %-20s %-6s %-6s %-6s %-7s %-10s %-14s", + "Instance", "Heap%", "GC OH", "CPU", "Leak?", "VThreads", "Status"); + System.out.println(RichRenderer.boxLine( + AnsiStyle.style(useColor, AnsiStyle.BOLD) + hdr + + AnsiStyle.style(useColor, AnsiStyle.RESET), WIDTH)); + System.out.println(RichRenderer.emptyLine(WIDTH)); + + for (InstanceMetrics m : instances) { + System.out.println(RichRenderer.boxLine(formatInstanceRow(useColor, m), WIDTH)); + } + + System.out.println(RichRenderer.emptyLine(WIDTH)); + System.out.println(RichRenderer.boxSeparator(WIDTH)); + System.out.println(RichRenderer.emptyLine(WIDTH)); + + // Aggregate row + String aggHeap = formatRange(stats.heapMin(), stats.heapMax(), "%"); + String aggGc = formatRange(stats.gcMin(), stats.gcMax(), "%"); + String aggCpu = formatRange(stats.cpuMin(), stats.cpuMax(), "%"); + String aggLeak = stats.leakCount() + "/" + instances.size(); + String aggVt = stats.vtTotal() > 0 ? String.format("%,d total", stats.vtTotal()) : "N/A"; + + String aggRow = String.format(" %-20s %-6s %-6s %-6s %-7s %-24s", + "Aggregate", aggHeap, aggGc, aggCpu, aggLeak, aggVt); + System.out.println(RichRenderer.boxLine( + AnsiStyle.style(useColor, AnsiStyle.BOLD) + aggRow + + AnsiStyle.style(useColor, AnsiStyle.RESET), WIDTH)); + + if (stats.worstTarget() != null && !stats.worstReason().isEmpty()) { + String worstMsg = " Worst: " + stats.worstTarget() + " \u2014 " + stats.worstReason(); + System.out.println(RichRenderer.boxLine( + AnsiStyle.style(useColor, AnsiStyle.YELLOW) + worstMsg + + AnsiStyle.style(useColor, AnsiStyle.RESET), WIDTH)); + } + + System.out.println(RichRenderer.boxFooter(useColor, null, WIDTH)); + } + + private String formatInstanceRow(boolean useColor, InstanceMetrics m) { + if (!m.reachable()) { + return String.format(" %-20s %-6s %-6s %-6s %-7s %-10s %s", + truncate(m.target(), 20), "N/A", "N/A", "N/A", "N/A", "N/A", + AnsiStyle.style(useColor, AnsiStyle.RED) + "\u2717 DOWN" + + AnsiStyle.style(useColor, AnsiStyle.RESET)); + } + + String heap = m.heapPercent() >= 0 ? String.format("%.0f%%", m.heapPercent()) : "N/A"; + String gc = m.gcOverhead() >= 0 ? String.format("%.1f%%", m.gcOverhead()) : "N/A"; + String cpu = m.cpuPercent() >= 0 ? String.format("%.0f%%", m.cpuPercent()) : "N/A"; + String leak = m.leakSuspected() + ? AnsiStyle.style(useColor, AnsiStyle.YELLOW) + "\u26a0 Yes" + AnsiStyle.style(useColor, AnsiStyle.RESET) + : "No"; + String vt = m.activeVThreads() > 0 ? String.format("%,d", m.activeVThreads()) : "0"; + + boolean warn = (m.heapPercent() >= 80) || (m.gcOverhead() >= 5) || m.leakSuspected(); + String status = warn + ? AnsiStyle.style(useColor, AnsiStyle.YELLOW) + "\u26a0 Warning" + AnsiStyle.style(useColor, AnsiStyle.RESET) + : AnsiStyle.style(useColor, AnsiStyle.GREEN) + "\u2713 Healthy" + AnsiStyle.style(useColor, AnsiStyle.RESET); + + return String.format(" %-20s %-6s %-6s %-6s %-7s %-10s %s", + truncate(m.target(), 20), heap, gc, cpu, leak, vt, status); + } + + private static String formatRange(double min, double max, String suffix) { + if (min < 0) return "N/A"; + if (Math.abs(max - min) < 0.05) return String.format("%.0f%s", min, suffix); + return String.format("%.0f-%.0f%s", min, max, suffix); + } + + private static String truncate(String s, int max) { + return s.length() <= max ? s : s.substring(0, max - 1) + "\u2026"; + } + + // ── Output: JSON ──────────────────────────────────────────────────────── + + private void printJson(List instances, AggregateStats stats) { + StringBuilder sb = new StringBuilder(); + sb.append("{\"instances\":["); + for (int i = 0; i < instances.size(); i++) { + if (i > 0) sb.append(','); + InstanceMetrics m = instances.get(i); + sb.append("{\"target\":\"").append(m.target()).append('"') + .append(",\"reachable\":").append(m.reachable()) + .append(",\"heapPercent\":").append(m.heapPercent()) + .append(",\"gcOverhead\":").append(m.gcOverhead()) + .append(",\"cpuPercent\":").append(m.cpuPercent()) + .append(",\"leakSuspected\":").append(m.leakSuspected()) + .append(",\"activeVThreads\":").append(m.activeVThreads()) + .append('}'); + } + sb.append("],\"aggregate\":{") + .append("\"heapMin\":").append(stats.heapMin()) + .append(",\"heapMax\":").append(stats.heapMax()) + .append(",\"heapAvg\":").append(stats.heapAvg()) + .append(",\"gcMin\":").append(stats.gcMin()) + .append(",\"gcMax\":").append(stats.gcMax()) + .append(",\"gcAvg\":").append(stats.gcAvg()) + .append(",\"cpuMin\":").append(stats.cpuMin()) + .append(",\"cpuMax\":").append(stats.cpuMax()) + .append(",\"cpuAvg\":").append(stats.cpuAvg()) + .append(",\"vtTotal\":").append(stats.vtTotal()) + .append(",\"leakCount\":").append(stats.leakCount()) + .append(",\"worstTarget\":\"").append(stats.worstTarget() != null ? stats.worstTarget() : "").append('"') + .append(",\"worstReason\":\"").append(stats.worstReason() != null ? stats.worstReason() : "").append('"') + .append("}}"); + System.out.println(sb); + } + + // ── Targets file ──────────────────────────────────────────────────────── + + private static List readTargetsFile(String filePath) { + List targets = new ArrayList<>(); + try { + for (String line : Files.readAllLines(Path.of(filePath))) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + targets.add(trimmed); + } + } + } catch (IOException e) { + System.err.println("Warning: could not read targets file '" + filePath + "': " + e.getMessage()); + } + return targets; + } + + private static void printHelp() { + System.out.println("Usage: argus cluster [targets...] [options]"); + System.out.println(); + System.out.println("Subcommands:"); + System.out.println(" scan Discover and display health of multiple JVM instances"); + System.out.println(" health Show aggregated health metrics"); + System.out.println(); + System.out.println("Options:"); + System.out.println(" --file=FILE Read host:port targets from file (one per line)"); + System.out.println(" --format=json Output as JSON"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" argus cluster scan localhost:9202 localhost:9203 localhost:9204"); + System.out.println(" argus cluster scan --file=targets.txt"); + System.out.println(" argus cluster health localhost:9202 localhost:9203"); + } +} diff --git a/argus-cli/src/main/resources/messages_en.properties b/argus-cli/src/main/resources/messages_en.properties index 385b517..23d1391 100644 --- a/argus-cli/src/main/resources/messages_en.properties +++ b/argus-cli/src/main/resources/messages_en.properties @@ -1,4 +1,5 @@ # Commands +cmd.cluster.desc=Aggregated health view across multiple JVM instances cmd.init.desc=Initialize Argus CLI configuration cmd.ps.desc=List running JVM processes cmd.histo.desc=Show heap object histogram @@ -17,6 +18,7 @@ cmd.diff.desc=Compare heap snapshots for leak detection cmd.report.desc=Comprehensive JVM diagnostic report # Headers +header.cluster=Cluster Health header.histo=Heap Histogram header.threads=Thread Summary header.gc=GC Statistics @@ -306,6 +308,7 @@ cmd.perfcounter.desc=Show JVM internal performance counters cmd.mbean.desc=Browse JMX MBeans (attributes, domains) # Errors +error.cluster.no.targets=No targets specified. Provide host:port arguments or --file=FILE. error.pid.required=PID is required error.pid.invalid=Invalid PID: {0} error.provider.none=No provider available for this command (PID: {0}) diff --git a/argus-cli/src/main/resources/messages_ja.properties b/argus-cli/src/main/resources/messages_ja.properties index 06a3431..7241ac6 100644 --- a/argus-cli/src/main/resources/messages_ja.properties +++ b/argus-cli/src/main/resources/messages_ja.properties @@ -1,4 +1,7 @@ # Commands +cmd.cluster.desc=\u8907\u6570\u306EJVM\u30A4\u30F3\u30B9\u30BF\u30F3\u30B9\u306E\u96C6\u7D04\u30D8\u30EB\u30B9\u30D3\u30E5\u30FC +header.cluster=\u30AF\u30E9\u30B9\u30BF\u30FC\u30D8\u30EB\u30B9 +error.cluster.no.targets=\u30BF\u30FC\u30B2\u30C3\u30C8\u304C\u6307\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002host:port\u5F15\u6570\u307E\u305F\u306F--file=FILE\u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002 cmd.init.desc=Argus CLI\u306E\u8A2D\u5B9A\u3092\u521D\u671F\u5316\u3059\u308B cmd.ps.desc=\u5B9F\u884C\u4E2D\u306EJVM\u30D7\u30ED\u30BB\u30B9\u306E\u4E00\u89A7\u3092\u8868\u793A cmd.histo.desc=\u30D2\u30FC\u30D7\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u30D2\u30B9\u30C8\u30B0\u30E9\u30E0\u3092\u8868\u793A diff --git a/argus-cli/src/main/resources/messages_ko.properties b/argus-cli/src/main/resources/messages_ko.properties index a4c3cfe..5e29988 100644 --- a/argus-cli/src/main/resources/messages_ko.properties +++ b/argus-cli/src/main/resources/messages_ko.properties @@ -1,4 +1,7 @@ # Commands +cmd.cluster.desc=\uC5EC\uB7EC JVM \uC778\uC2A4\uD134\uC2A4\uC758 \uC9D1\uACC4 \uC0C1\uD0DC \uBDF0 +header.cluster=\uD074\uB7EC\uC2A4\uD130 \uC0C1\uD0DC +error.cluster.no.targets=\uB300\uC0C1\uC774 \uC9C0\uC815\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. host:port \uC778\uC218 \uB610\uB294 --file=FILE\uC744 \uC0AC\uC6A9\uD558\uC138\uC694. cmd.init.desc=Argus CLI \uC124\uC815 \uCD08\uAE30\uD654 cmd.ps.desc=\uC2E4\uD589 \uC911\uC778 JVM \uD504\uB85C\uC138\uC2A4 \uBAA9\uB85D \uD45C\uC2DC cmd.histo.desc=\uD799 \uAC1D\uCCB4 \uD788\uC2A4\uD1A0\uADF8\uB7A8 \uD45C\uC2DC diff --git a/argus-cli/src/main/resources/messages_zh.properties b/argus-cli/src/main/resources/messages_zh.properties index a7a8f01..59015a0 100644 --- a/argus-cli/src/main/resources/messages_zh.properties +++ b/argus-cli/src/main/resources/messages_zh.properties @@ -1,4 +1,7 @@ # Commands +cmd.cluster.desc=\u591A\u4E2AJVM\u5B9E\u4F8B\u7684\u805A\u5408\u5065\u5EB7\u89C6\u56FE +header.cluster=\u96C6\u7FA4\u5065\u5EB7 +error.cluster.no.targets=\u672A\u6307\u5B9A\u76EE\u6807\u3002\u8BF7\u63D0\u4F9Bhost:port\u53C2\u6570\u6216--file=FILE\u3002 cmd.init.desc=\u521D\u59CB\u5316 Argus CLI \u914D\u7F6E cmd.ps.desc=\u5217\u51FA\u6B63\u5728\u8FD0\u884C\u7684 JVM \u8FDB\u7A0B cmd.histo.desc=\u663E\u793A\u5806\u5BF9\u8C61\u76F4\u65B9\u56FE diff --git a/argus-cli/src/test/java/io/argus/cli/cluster/PrometheusTextParserTest.java b/argus-cli/src/test/java/io/argus/cli/cluster/PrometheusTextParserTest.java new file mode 100644 index 0000000..6be01bb --- /dev/null +++ b/argus-cli/src/test/java/io/argus/cli/cluster/PrometheusTextParserTest.java @@ -0,0 +1,121 @@ +package io.argus.cli.cluster; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class PrometheusTextParserTest { + + private static final String SAMPLE = """ + # HELP argus_heap_used_percent Heap used percent + # TYPE argus_heap_used_percent gauge + argus_heap_used_percent 56.3 + # HELP argus_gc_overhead_percent GC overhead percent + # TYPE argus_gc_overhead_percent gauge + argus_gc_overhead_percent 2.1 + # HELP argus_cpu_process_percent Process CPU percent + # TYPE argus_cpu_process_percent gauge + argus_cpu_process_percent 12.0 + # HELP argus_virtual_threads_active Active virtual threads + # TYPE argus_virtual_threads_active gauge + argus_virtual_threads_active 1234.0 + # HELP argus_memory_leak_suspected Memory leak flag + # TYPE argus_memory_leak_suspected gauge + argus_memory_leak_suspected 0.0 + """; + + private static final String SAMPLE_WITH_LABELS = """ + # HELP jvm_memory_used_bytes Memory used + # TYPE jvm_memory_used_bytes gauge + jvm_memory_used_bytes{area="heap",id="G1 Eden Space"} 12345678.0 + jvm_gc_overhead_percent{gc="G1"} 8.3 + process_cpu_usage 0.67 + """; + + @Test + void parsesBasicMetrics() { + Map m = PrometheusTextParser.parse(SAMPLE); + assertEquals(56.3, m.get("argus_heap_used_percent"), 0.001); + assertEquals(2.1, m.get("argus_gc_overhead_percent"), 0.001); + assertEquals(12.0, m.get("argus_cpu_process_percent"), 0.001); + assertEquals(1234.0, m.get("argus_virtual_threads_active"), 0.001); + assertEquals(0.0, m.get("argus_memory_leak_suspected"), 0.001); + } + + @Test + void skipsCommentLines() { + Map m = PrometheusTextParser.parse(SAMPLE); + assertFalse(m.containsKey("# HELP argus_heap_used_percent Heap used percent")); + assertFalse(m.containsKey("# TYPE argus_heap_used_percent gauge")); + } + + @Test + void parsesMetricsWithLabels() { + Map m = PrometheusTextParser.parse(SAMPLE_WITH_LABELS); + assertTrue(m.containsKey("jvm_memory_used_bytes")); + assertTrue(m.containsKey("jvm_gc_overhead_percent")); + assertEquals(8.3, m.get("jvm_gc_overhead_percent"), 0.001); + assertEquals(0.67, m.get("process_cpu_usage"), 0.001); + } + + @Test + void returnsEmptyMapForNull() { + Map m = PrometheusTextParser.parse(null); + assertTrue(m.isEmpty()); + } + + @Test + void returnsEmptyMapForEmptyString() { + Map m = PrometheusTextParser.parse(""); + assertTrue(m.isEmpty()); + } + + @Test + void handlesOnlyComments() { + Map m = PrometheusTextParser.parse("# HELP foo bar\n# TYPE foo gauge\n"); + assertTrue(m.isEmpty()); + } + + @Test + void extractsMappedToInstanceMetrics() { + Map raw = PrometheusTextParser.parse(SAMPLE); + ClusterHealthAggregator.InstanceMetrics metrics = + ClusterHealthAggregator.extract("localhost:9202", raw); + assertEquals("localhost:9202", metrics.target()); + assertEquals(56.3, metrics.heapPercent(), 0.001); + assertEquals(2.1, metrics.gcOverhead(), 0.001); + assertEquals(12.0, metrics.cpuPercent(), 0.001); + assertFalse(metrics.leakSuspected()); + assertEquals(1234L, metrics.activeVThreads()); + assertTrue(metrics.reachable()); + } + + @Test + void aggregateComputesRanges() { + Map raw1 = PrometheusTextParser.parse(SAMPLE); + Map raw2 = Map.of( + "argus_heap_used_percent", 89.0, + "argus_gc_overhead_percent", 8.3, + "argus_cpu_process_percent", 67.0, + "argus_virtual_threads_active", 2456.0, + "argus_memory_leak_suspected", 1.0 + ); + ClusterHealthAggregator.InstanceMetrics m1 = + ClusterHealthAggregator.extract("localhost:9202", raw1); + ClusterHealthAggregator.InstanceMetrics m2 = + ClusterHealthAggregator.extract("localhost:9203", raw2); + + ClusterHealthAggregator.AggregateStats stats = + ClusterHealthAggregator.aggregate(java.util.List.of(m1, m2)); + + assertEquals(56.3, stats.heapMin(), 0.1); + assertEquals(89.0, stats.heapMax(), 0.1); + assertEquals(2.1, stats.gcMin(), 0.1); + assertEquals(8.3, stats.gcMax(), 0.1); + assertEquals(3690L, stats.vtTotal()); + assertEquals(1, stats.leakCount()); + assertEquals("localhost:9203", stats.worstTarget()); + } +}