diff --git a/README.md b/README.md index 6f6e03b..62a2903 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # systools +![](/public/systools.png) + ## Install ```bash @@ -16,6 +18,12 @@ python monitor.py --target redis --redis-url redis://localhost:6379/0 --interval # linux python monitor.py --target linux --interval 5 --output json + +# web dashboard (Flask) +export REDIS_URL=redis://localhost:6379/0 +export KAFKA_BOOTSTRAP=localhost:9092 +python web/app.py +# 브라우저에서 http://localhost:8000 접속 ``` - `--target`: redis | linux | kafka | jvm diff --git a/kafka/collector.py b/kafka/collector.py index 8a38588..f943615 100644 --- a/kafka/collector.py +++ b/kafka/collector.py @@ -1,3 +1,4 @@ +from __future__ import annotations import time from typing import Any, Dict, List @@ -16,7 +17,7 @@ def __init__(self, bootstrap_servers: str | None = None, group_id: str | None = self.group_id = group_id self.timeout_ms = timeout_ms - def _build_consumer(self) -> KafkaConsumer | None: + def _build_consumer(self) -> "KafkaConsumer | None": if KafkaConsumer is None or not self.bootstrap_servers: return None try: @@ -34,7 +35,7 @@ def _build_consumer(self) -> KafkaConsumer | None: except Exception: return None - def _compute_topics_partitions(self, consumer: KafkaConsumer) -> Dict[str, int]: + def _compute_topics_partitions(self, consumer: "KafkaConsumer") -> Dict[str, int]: num_topics = 0 num_partitions = 0 try: @@ -48,7 +49,7 @@ def _compute_topics_partitions(self, consumer: KafkaConsumer) -> Dict[str, int]: pass return {"num_topics": num_topics, "num_partitions": num_partitions} - def _num_brokers(self, consumer: KafkaConsumer) -> int | None: + def _num_brokers(self, consumer: "KafkaConsumer") -> int | None: try: cluster = consumer._client.cluster # 내부 속성 사용(없으면 None) if cluster: @@ -57,12 +58,12 @@ def _num_brokers(self, consumer: KafkaConsumer) -> int | None: return None return None - def _group_lag(self, consumer: KafkaConsumer) -> int | None: + def _group_lag(self, consumer: "KafkaConsumer") -> int | None: if not self.group_id: return None try: topics = list(consumer.topics() or []) - tps: List[TopicPartition] = [] + tps: List["TopicPartition"] = [] for t in topics: parts = consumer.partitions_for_topic(t) or [] for p in parts: diff --git a/monitor.py b/monitor.py index ad606ad..c983447 100644 --- a/monitor.py +++ b/monitor.py @@ -9,8 +9,9 @@ from redis.collector import RedisMetricsCollector from linux.collector import LinuxMetricsCollector -from kafka.collector import KafkaMetricsCollector from jvm.collector import JvmMetricsCollector +from importlib.machinery import SourceFileLoader +from pathlib import Path as _Path def load_config(args) -> dict: @@ -83,7 +84,10 @@ def main(): elif target == "linux": collector = LinuxMetricsCollector() elif target == "kafka": - collector = KafkaMetricsCollector( + _kafka_mod = SourceFileLoader( + "systools_kafka_collector", str(_Path(__file__).resolve().parent / "kafka" / "collector.py") + ).load_module() + collector = _kafka_mod.KafkaMetricsCollector( bootstrap_servers=args.kafka_bootstrap, group_id=args.kafka_group, ) @@ -98,7 +102,7 @@ def main(): metrics = collector.collect_all() print_output(metrics, config["output"]) except Exception as e: - print(f"[ERROR] {e}", file=sys.stderr) + print(f"[WARN] collect failed: {e}", file=sys.stderr) if interval <= 0: break time.sleep(interval) diff --git a/public/systools.png b/public/systools.png new file mode 100644 index 0000000..456d545 Binary files /dev/null and b/public/systools.png differ diff --git a/redis/collector.py b/redis/collector.py index 03974d2..64a4d18 100644 --- a/redis/collector.py +++ b/redis/collector.py @@ -1,8 +1,10 @@ import json import time from typing import Any, Dict, Tuple - -import redis +import importlib +import sys +import sysconfig +from pathlib import Path class RedisMetricsCollector: @@ -10,7 +12,32 @@ def __init__(self, redis_url: str, ping_samples: int = 3, ping_timeout_ms: int = self.redis_url = redis_url self.ping_samples = max(1, int(ping_samples)) self.ping_timeout_ms = max(1, int(ping_timeout_ms)) - self.client = redis.from_url(redis_url, decode_responses=True, socket_timeout=ping_timeout_ms / 1000.0) + # 외부 패키지 'redis'와 로컬 패키지명이 충돌하므로, site-packages에서 강제로 로드 + site_purelib = sysconfig.get_paths().get("purelib") + restore = False + # sys.modules에 로컬 패키지가 올라가 있으면 제거 + try: + mod = sys.modules.get("redis") + if mod and hasattr(mod, "__file__"): + mod_path = Path(mod.__file__ or "").resolve() + project_root = Path(__file__).resolve().parents[1] + if str(project_root) in str(mod_path): + sys.modules.pop("redis", None) + except Exception: + pass + try: + if site_purelib and (not sys.path or sys.path[0] != site_purelib): + sys.path.insert(0, site_purelib) + restore = True + redis_py = importlib.import_module("redis") + finally: + if restore: + # site-packages를 임시로 앞에 둔 후 원복 + try: + sys.path.remove(site_purelib) + except Exception: + pass + self.client = redis_py.from_url(redis_url, decode_responses=True, socket_timeout=ping_timeout_ms / 1000.0) def _safe_get(self, dct: Dict[str, Any], key: str, default=None): try: diff --git a/requirements.txt b/requirements.txt index 6cff739..fdaad9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ redis==5.0.1 PyYAML==6.0.2 tabulate==0.9.0 kafka-python==2.0.2 +Flask==3.0.3 +six==1.16.0 diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..b71e2ff --- /dev/null +++ b/web/app.py @@ -0,0 +1,68 @@ +from flask import Flask, render_template, request +import os +import time +import sys +from pathlib import Path + +# 프로젝트 루트를 import 경로의 최우선에 추가 (외부 패키지 'redis'보다 로컬 'redis/' 우선) +project_root = str(Path(__file__).resolve().parents[1]) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from redis.collector import RedisMetricsCollector +from linux.collector import LinuxMetricsCollector +from jvm.collector import JvmMetricsCollector +from importlib.machinery import SourceFileLoader +from types import ModuleType + +# kafka collector는 외부 패키지 이름과 충돌을 피하기 위해 파일 경로로 동적 로드 +kafka_collector_path = Path(project_root) / "kafka" / "collector.py" +KafkaMetricsCollector = SourceFileLoader("systools_kafka_collector", str(kafka_collector_path)).load_module().KafkaMetricsCollector + +app = Flask(__name__, template_folder="templates", static_folder="static") + + +def collect_safe(collector_name: str, fn): + try: + return {"name": collector_name, "data": fn(), "error": None} + except Exception as e: + return {"name": collector_name, "data": None, "error": str(e)} + + +@app.route("/") +def index(): + targets = request.args.get("targets", "redis,linux,kafka,jvm") + target_list = [t.strip() for t in targets.split(",") if t.strip()] + + results = [] + now = int(time.time()) + + if "redis" in target_list: + redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0") + ping_samples = int(os.environ.get("REDIS_PING_SAMPLES", "3")) + ping_timeout_ms = int(os.environ.get("REDIS_PING_TIMEOUT_MS", "500")) + rc = RedisMetricsCollector(redis_url, ping_samples, ping_timeout_ms) + results.append(collect_safe("redis", rc.collect_all)) + + if "linux" in target_list: + lc = LinuxMetricsCollector() + results.append(collect_safe("linux", lc.collect_all)) + + if "kafka" in target_list: + bootstrap = os.environ.get("KAFKA_BOOTSTRAP") + group_id = os.environ.get("KAFKA_GROUP") + kc = KafkaMetricsCollector(bootstrap_servers=bootstrap, group_id=group_id) + results.append(collect_safe("kafka", kc.collect_all)) + + if "jvm" in target_list: + jmx_url = os.environ.get("JMX_URL") + jc = JvmMetricsCollector(jmx_url=jmx_url) + results.append(collect_safe("jvm", jc.collect_all)) + + return render_template("index.html", results=results, ts=now) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) + + diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..a6401f4 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,22 @@ +:root{--bg:#0b1020;--fg:#e6e9ef;--muted:#9aa4b2;--card:#121833;--ok:#18a957;--err:#e14b53;--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--fg);font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Apple Color Emoji","Segoe UI Emoji"} +header{padding:10px 14px;border-bottom:1px solid #1c2444;display:flex;align-items:center;justify-content:space-between} +h1{margin:0;font-size:16px} +.meta{font-size:12px} +.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px;padding:10px} +.card{background:var(--card);border:1px solid #1c2444;border-radius:8px;overflow:hidden;padding:8px} +.compact .row{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px} +.title{display:flex;align-items:center;gap:8px} +.name{font-weight:600} +.dot{width:8px;height:8px;border-radius:50%;display:inline-block} +.dot.ok{background:var(--ok)} +.dot.err{background:var(--err)} +.muted{color:var(--muted)} +.section{display:flex;flex-direction:column;gap:4px} +.group{margin-top:6px;padding-top:4px;border-top:1px dashed #1c2444} +.group-title{font-size:12px;color:var(--muted);margin-bottom:2px} +.kv{display:grid;grid-template-columns: 1fr 1fr;gap:6px;align-items:center;background:#0c1226;border:1px solid #1c2444;border-radius:6px;padding:6px} +.kv .key{color:#c6d0f5;font-family:var(--mono);font-size:12px} +.kv .val{font-family:var(--mono);font-size:12px;color:#e6e9ef;word-break:break-all} + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..1b49e92 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,68 @@ + + + + + + systools + + + +
+

systools

+
+ updated {{ ts }} +
+
+
+ {% for r in results %} +
+
+
+ + {{ r.name }} +
+
+ {% if r.error %}unavailable{% else %}ok{% endif %} +
+
+ {% if r.error %} +
접속 불가: {{ r.error }}
+ {% else %} + {% macro render_obj(obj, level=0) -%} + {% if obj is mapping %} +
+ {% for k, v in obj.items() %} + {% if v is mapping %} +
+
{{ k }}
+ {{ render_obj(v, level+1) }} +
+ {% elif v is sequence and (v is not string) %} +
+
{{ k }}
+
{{ v|join(', ') }}
+
+ {% else %} +
+
{{ k }}
+
{{ v if v is not none else '-' }}
+
+ {% endif %} + {% endfor %} +
+ {% else %} +
+
value
+
{{ obj }}
+
+ {% endif %} + {%- endmacro %} + {{ render_obj(r.data, 0) }} + {% endif %} +
+ {% endfor %} +
+ + + +