Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# systools

![](/public/systools.png)

## Install

```bash
Expand All @@ -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
Expand Down
11 changes: 6 additions & 5 deletions kafka/collector.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import time
from typing import Any, Dict, List

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
Expand All @@ -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)
Expand Down
Binary file added public/systools.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 30 additions & 3 deletions redis/collector.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,43 @@
import json
import time
from typing import Any, Dict, Tuple

import redis
import importlib
import sys
import sysconfig
from pathlib import Path


class RedisMetricsCollector:
def __init__(self, redis_url: str, ping_samples: int = 3, ping_timeout_ms: int = 500):
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:
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

68 changes: 68 additions & 0 deletions web/app.py
Original file line number Diff line number Diff line change
@@ -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")))


22 changes: 22 additions & 0 deletions web/static/style.css
Original file line number Diff line number Diff line change
@@ -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}

68 changes: 68 additions & 0 deletions web/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>systools</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<h1>systools</h1>
<div class="meta">
<span class="muted">updated {{ ts }}</span>
</div>
</header>
<main class="grid">
{% for r in results %}
<section class="card compact">
<div class="row">
<div class="title">
<span class="dot {% if r.error %}err{% else %}ok{% endif %}"></span>
<span class="name">{{ r.name }}</span>
</div>
<div class="status">
{% if r.error %}<span class="muted">unavailable</span>{% else %}<span class="muted">ok</span>{% endif %}
</div>
</div>
{% if r.error %}
<div class="kv muted">접속 불가: {{ r.error }}</div>
{% else %}
{% macro render_obj(obj, level=0) -%}
{% if obj is mapping %}
<div class="section level-{{ level }}">
{% for k, v in obj.items() %}
{% if v is mapping %}
<div class="group">
<div class="group-title">{{ k }}</div>
{{ render_obj(v, level+1) }}
</div>
{% elif v is sequence and (v is not string) %}
<div class="kv">
<div class="key">{{ k }}</div>
<div class="val">{{ v|join(', ') }}</div>
</div>
{% else %}
<div class="kv">
<div class="key">{{ k }}</div>
<div class="val">{{ v if v is not none else '-' }}</div>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="kv">
<div class="key">value</div>
<div class="val">{{ obj }}</div>
</div>
{% endif %}
{%- endmacro %}
{{ render_obj(r.data, 0) }}
{% endif %}
</section>
{% endfor %}
</main>
</body>
</html>