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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@ spool.db-*
dev/

# --- Dashboard (Next.js, M9) -------------------------------------------------
# Barındırma Fly (collector'dan ayrı bir app) — Vercel değil, bkz. CLAUDE.md §9.1.
node_modules/
.next/
out/
next-env.d.ts
.vercel
*.tsbuildinfo

# --- Editör / işletim sistemi ------------------------------------------------
.DS_Store
Expand All @@ -72,3 +73,7 @@ Thumbs.db
# Makinede duran, repoya girmeyen yardımcı dosyalar.
CLAUDE.md
md/

# --- YEREL ÖRNEK DASHBOARD GÖRSELLERİ VE DEPLOY SCRİPTİ ----------------------------------------
dashboard/example
dashboard/deploy.sh
231 changes: 129 additions & 102 deletions README.md

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions agent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@

VERIFY_FLAG = "--verify"

USAGE = """Kullanım:
python -m agent agent servisini çalıştırır
python -m agent --verify collector bağlantısını sınar, sonra çıkar
USAGE = """Usage:
python -m agent run the agent service
python -m agent --verify test the collector connection, then exit
"""


Expand All @@ -49,7 +49,7 @@ def main(argv: list[str] | None = None) -> int:
# agent hiç açılmasın, yanlış ayarla çalışmasın.
config = loader.load()
except ConfigError as exc:
print(f"config hatası: {exc}", file=sys.stderr, flush=True)
print(f"configuration error: {exc}", file=sys.stderr, flush=True)
return EXIT_CONFIG_ERROR

if args == [VERIFY_FLAG]:
Expand All @@ -63,8 +63,8 @@ def main(argv: list[str] | None = None) -> int:
# Restart=on-failure ile çalışıyor, yani yeniden başlatmaz.
if store.is_deleted():
print(
f"Bu cihaz silindi ({store.deleted_marker_path}); agent başlatılmadı.\n"
"Kurulumu tamamen kaldırmak için: sudo /opt/tracebox/uninstall.sh --yes",
f"This host was deleted ({store.deleted_marker_path}); the agent did not start.\n"
"To remove the installation completely: sudo /opt/tracebox/uninstall.sh --yes",
flush=True,
)
return EXIT_OK
Expand All @@ -78,10 +78,10 @@ def main(argv: list[str] | None = None) -> int:
with SingleWriterLock(store.directory):
loop.run(loader, store, log_source)
except RuntimeError as exc:
print(f"başlatılamadı: {exc}", file=sys.stderr, flush=True)
print(f"could not start: {exc}", file=sys.stderr, flush=True)
return EXIT_ALREADY_RUNNING
except OSError as exc:
print(f"dosya erişim hatası: {exc}", file=sys.stderr, flush=True)
print(f"file access error: {exc}", file=sys.stderr, flush=True)
return EXIT_CONFIG_ERROR

return EXIT_OK
Expand All @@ -96,10 +96,10 @@ def _run_verify(config) -> int:
result = verify(config)

if result.ok:
print(f"✓ Kuruldu ve bağlandı — {result.detail}", flush=True)
print(f"✓ Installed and connected — {result.detail}", flush=True)
return EXIT_OK

print(f"✗ Bağlanamadı — {result.detail}", file=sys.stderr, flush=True)
print(f"✗ Could not connect — {result.detail}", file=sys.stderr, flush=True)
return EXIT_VERIFY_FAILED


Expand Down
91 changes: 46 additions & 45 deletions agent/config.example.toml
Original file line number Diff line number Diff line change
@@ -1,68 +1,69 @@
# =============================================================================
# TraceBox Agent — örnek yapılandırma
# TraceBox Agent — example configuration
#
# Gerçek dosya: /etc/tracebox/config.toml (sahip: tracebox, izin: 600).
# Agent bu dosyayı yalnızca okur; yazdığı tek dosya state.json'dır.
# Döngü her tick'te dosyayı yeniden okuduğu için değişiklikler servisi yeniden
# başlatmadan geçerli olur.
# The real file lives at /etc/tracebox/config.toml (owner: tracebox, mode 600).
# The agent only reads this file; the only file it writes is state.json.
# The loop re-reads the file on every tick, so changes take effect without
# restarting the service.
# =============================================================================

# --- Bağlantı ----------------------------------------------------------------
# Collector'ın adresi; agent tüm isteklerini bu adrese gönderir.
# --- Connection --------------------------------------------------------------
# Address of the collector; the agent sends every request there.
collector_url = "https://tracebox-collector.fly.dev"

# Cihaz anahtarı. Agent'ın kimliği yalnızca bu değerdir: device_id'yi sunucu
# anahtardan çözer. Sunucuda düz hali değil SHA-256
# özeti saklanır (devices.key_hash), bu yüzden kaybedilen anahtar geri alınamaz.
device_key = "tbx_live_BURAYA_ANAHTARINI_YAZ"
# Device key. It is the agent's only identity: the server resolves device_id
# from the key. The server stores the SHA-256 digest, not the plain value
# (devices.key_hash), so a lost key cannot be recovered.
device_key = "tbx_live_PUT_YOUR_KEY_HERE"

# --- Zamanlama ---------------------------------------------------------------
# Ölçüm alma sıklığı. Küçültmek zaman çözünürlüğünü artırır; CPU kullanımı ve
# saklanan satır sayısı bu değerle ters orantılı büyür.
# --- Timing ------------------------------------------------------------------
# How often a sample is taken. A smaller value raises the time resolution; CPU
# usage and the number of stored rows grow inversely with it.
collect_interval_seconds = 5

# Spool'da birikenin gönderilme sıklığı; aradaki ölçümler tek HTTP isteğinde
# birleştirilir. Kod bu değeri 10 saniyeyle floor'lar — daha küçük bir değer
# yazılsa da 10 kullanılır. Eşik aşımındaki acil flush bu aralığı beklemez.
send_interval_seconds = 30
# How often whatever piled up in the spool is sent; the samples in between are
# merged into a single HTTP request. The code floors this at 10 seconds — a
# smaller value is accepted but 10 is used. An urgent flush past a threshold
# does not wait for this interval.
send_interval_seconds = 10

# Komut kuyruğunun yoklanma sıklığı. pause/resume/delete komutlarının cihaza
# ulaşma gecikmesi en fazla bu kadardır.
# How often the command queue is polled. This is the longest a pause/resume/
# delete command can take to reach the device.
command_poll_seconds = 10

# --- Acil gönderim (flush) eşikleri ------------------------------------------
# Ölçüm bu yüzdeleri aştığında spool, sıradaki normal gönderim beklenmeden
# hemen boşaltılır.
# --- Urgent send (flush) thresholds ------------------------------------------
# When a sample goes past these percentages the spool is emptied right away,
# without waiting for the next regular send.
flush_cpu_threshold = 90
flush_ram_threshold = 90
flush_disk_threshold = 95

# Bir acil gönderimden sonra yenisi için beklenecek süre. Bu süre içinde eşik
# tekrar aşılsa da flush yapılmaz; veri spool'da birikmeye devam eder ve
# sıradaki gönderimde çıkar.
flush_cooldown_seconds = 20
# How long to wait after an urgent send before another one may happen. Crossing
# a threshold again within that window does not flush; the data keeps piling up
# in the spool and leaves with the next send.
flush_cooldown_seconds = 10

# --- Spool (yerel bekleme alanı) sınırları -----------------------------------
# Spool bir ring buffer'dır: iki sınırdan biri aşıldığında en eski kayıt silinir,
# en yeni veri korunur. Uzun bir ağ kesintisinde spool'un diski doldurmasını bu
# iki sınır engeller.
# --- Spool (local staging area) limits ---------------------------------------
# The spool is a ring buffer: when either limit is crossed the oldest record is
# dropped and the newest data is kept. These two limits are what stops the
# spool from filling the disk during a long network outage.
spool_max_age_days = 10
spool_max_size_mb = 200

# --- Eklentiler (add-on) -----------------------------------------------------
# Liste boşken yalnızca çekirdek metrikler toplanır; buraya eklenen her değer
# ilgili alanı doldurur, kapalı olanlar null kalır.
# --- Add-ons -----------------------------------------------------------------
# With an empty list only the core metrics are collected; every value added
# here fills the matching field, and the ones left out stay null.
#
# Kullanılabilir değerler:
# "temperature" CPU sıcaklığı -> metrics.temperature_c
# "swap" swap kullanımı -> metrics.swap_used_mb
# "load_avg" yük ortalaması (Linux) -> metrics.load_avg_1/5/15
# "gpu" GPU kullanımı + VRAM -> metrics.gpu_usage_percent, gpu_vram_used_mb
# "external_ip" dış IP (statik) -> devices.external_ip
# Değeri agent GÖNDERMEZ: isteği alan taraf (collector)
# bağlantının kaynak IP'sinden yazar. Buradaki tercih
# yalnızca "yazılsın mı" sorusunu cevaplar.
# "crash_processes" flush anında en çok kaynak yiyen 5 süreç -> crash_snapshots
# Available values:
# "temperature" CPU temperature -> metrics.temperature_c
# "swap" swap usage -> metrics.swap_used_mb
# "load_avg" load average (Linux) -> metrics.load_avg_1/5/15
# "gpu" GPU usage + VRAM -> metrics.gpu_usage_percent, gpu_vram_used_mb
# "external_ip" external IP (static) -> devices.external_ip
# The agent does NOT send the value: the receiving side
# (the collector) writes it from the connection's source
# IP. The choice here only answers "should it be written".
# "crash_processes" the 5 heaviest processes at flush time -> crash_snapshots
#
# Örnek: enabled_addons = ["swap", "load_avg"]
# Example: enabled_addons = ["swap", "load_avg"]
enabled_addons = []
32 changes: 16 additions & 16 deletions agent/core/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,18 @@ def fetch(self, config) -> list[Command]:
try:
response = self._client.get(url, headers=headers)
except httpx.HTTPError as error:
raise CommandError(f"bağlanılamadı ({error.__class__.__name__})") from error
raise CommandError(f"could not connect ({error.__class__.__name__})") from error

if response.status_code == 401:
raise CommandError("cihaz anahtarı reddedildi (401)")
raise CommandError("device key rejected (401)")

if response.status_code != 200:
raise CommandError(f"HTTP {response.status_code}")

try:
body = response.json()
except ValueError as error:
raise CommandError("yanıt JSON değil") from error
raise CommandError("response is not JSON") from error

return _parse(body)

Expand All @@ -108,7 +108,7 @@ def _parse(body) -> list[Command]:
komut yüzünden diğerlerini (özellikle `resume`u) kaybetmek daha kötüdür.
"""
if not isinstance(body, dict) or not isinstance(body.get("commands"), list):
raise CommandError("yanıt beklenen şekilde değil")
raise CommandError("response has an unexpected shape")

commands = []
for item in body["commands"]:
Expand Down Expand Up @@ -144,11 +144,11 @@ def apply_commands(commands, *, config, state, store, spool, shipper, log) -> Co
if state.logging_enabled != wanted:
state.logging_enabled = wanted
state_changed = True
log(f"[cmd] {command.type} uygulandı — logging_enabled={wanted}")
log(f"[cmd] {command.type} applied — logging_enabled={wanted}")
else:
# Ack henüz ulaşmadığı için tekrar gönderilmiş komut. Uygulama
# idempotent: durum zaten istenen değerde.
log(f"[cmd] {command.type} zaten uygulanmış — ack tekrar denenecek")
log(f"[cmd] {command.type} was already applied — retrying the ack")

# Zaten ack listesinde olsa bile buraya yazılır: komutun tekrar
# gelmesi ack'in ulaşmadığı anlamına gelir, yani tekrar denenmeli.
Expand All @@ -160,7 +160,7 @@ def apply_commands(commands, *, config, state, store, spool, shipper, log) -> Co
# sayar ve bir daha vermezdi; yani agent'ın anlamadığı bir talimat
# sessizce uygulanmış görünürdü. Ack edilmeyince komut kuyrukta kalır
# ve agent güncellendiğinde uygulanır.
log(f"[cmd] bilinmeyen komut türü '{command.type}' — yok sayıldı (ack edilmedi)")
log(f"[cmd] unknown command type '{command.type}' — ignored (not acked)")

return CommandResult(applied_ids=applied, state_changed=state_changed)

Expand All @@ -186,10 +186,10 @@ def ack_now(applied_ids: list[str], *, config, shipper, log) -> list[str]:

result = shipper.send_acks(config, applied_ids)
if not result.ok:
log(f"[cmd] ack gönderilemedi: {result.detail} — sonraki gönderime bırakıldı")
log(f"[cmd] could not send ack: {result.detail} — deferred to the next ship")
return []

log(f"[cmd] {len(applied_ids)} komut ack'lendi")
log(f"[cmd] acked {len(applied_ids)} command(s)")
return list(applied_ids)


Expand All @@ -205,20 +205,20 @@ def _delete(command, *, config, store, spool, shipper, log) -> bool:
ve ack hiç atılamazdı; sunucu satırı erken silinseydi agent 401 alır,
komutu hiç göremezdi.
"""
log("[cmd] delete alındıönce ack gönderiliyor.")
log("[cmd] delete receivedsending the ack first.")

result = shipper.send_acks(config, [command.id])
if not result.ok:
log(f"[cmd] delete ack gönderilemedi: {result.detail} — silme ertelendi.")
log(f"[cmd] could not send delete ack: {result.detail} — deletion postponed.")
return False

log("[cmd] ack onaylandı: cihaz kaydı sunucudan silindi. Yerel temizlik başlıyor.")
log("[cmd] ack confirmed: the host record was deleted on the server. Wiping locally.")

spool.wipe()
log(f"[cmd] spool silindi: {spool.path}")
log(f"[cmd] spool wiped: {spool.path}")

store.wipe()
log(f"[cmd] state silindi: {store.path}")
log(f"[cmd] state wiped: {store.path}")

# Kalanı (systemd servisi, /opt, /etc ve anahtarın kendisi) agent SİLEMEZ:
# yetkisiz `tracebox` kullanıcısıyla, NoNewPrivileges=yes ve
Expand All @@ -229,7 +229,7 @@ def _delete(command, *, config, store, spool, shipper, log) -> bool:
# tracebox-uninstall.path onu görür ve uninstall.sh'i çalıştırır. Böylece
# kaldırma, agent'ın yetkisini artırmadan tamamlanır.
marker = store.mark_deleted()
log(f"[cmd] kaldırma işareti bırakıldı: {marker}")
log("[cmd] kaldırma tamamlanmazsa elle: sudo /opt/tracebox/uninstall.sh --yes")
log(f"[cmd] uninstall marker written: {marker}")
log("[cmd] if the removal does not finish: sudo /opt/tracebox/uninstall.sh --yes")

return True
33 changes: 16 additions & 17 deletions agent/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,14 @@ class Config:

# --- Zamanlama (saniye) ---
collect_interval_seconds: int = 5
send_interval_seconds: int = 30
send_interval_seconds: int = 10
command_poll_seconds: int = 10

# --- Acil gönderim eşikleri (yüzde) ---
flush_cpu_threshold: int = 90
flush_ram_threshold: int = 90
flush_disk_threshold: int = 95
flush_cooldown_seconds: int = 20
flush_cooldown_seconds: int = 10

# --- Spool sınırları ---
spool_max_age_days: int = 10
Expand Down Expand Up @@ -112,9 +112,8 @@ def check_permissions(path: Path, mode: int, *, warn) -> bool:
return True

warn(
f"{path} izinleri fazla açık ({stat.filemode(mode)}); cihaz anahtarını "
f"bu makinedeki başka kullanıcılar okuyabilir. Düzeltmek için: "
f"chmod 600 {path}"
f"permissions on {path} are too open ({stat.filemode(mode)}); other users "
f"on this machine can read the device key. To fix: chmod 600 {path}"
)
return False

Expand All @@ -131,9 +130,9 @@ def _positive_int(raw: dict, key: str, default: int) -> int:

value = raw[key]
if isinstance(value, bool) or not isinstance(value, int):
raise ConfigError(f"'{key}' tam sayı olmalı, alınan: {value!r}")
raise ConfigError(f"'{key}' must be an integer, got: {value!r}")
if value <= 0:
raise ConfigError(f"'{key}' sıfırdan büyük olmalı, alınan: {value}")
raise ConfigError(f"'{key}' must be greater than zero, got: {value}")
return value


Expand All @@ -146,19 +145,19 @@ def _parse(raw: dict, *, warn) -> Config:
for key in REQUIRED_KEYS:
value = raw.get(key)
if not isinstance(value, str) or not value.strip():
raise ConfigError(f"zorunlu alan eksik veya boş: '{key}'")
raise ConfigError(f"required field is missing or empty: '{key}'")

send_interval = _positive_int(raw, "send_interval_seconds", 30)
send_interval = _positive_int(raw, "send_interval_seconds", 10)
if send_interval < MIN_SEND_INTERVAL_SECONDS:
warn(
f"send_interval_seconds={send_interval} alt sınırın altında; "
f"{MIN_SEND_INTERVAL_SECONDS} kullanılıyor."
f"send_interval_seconds={send_interval} is below the minimum; "
f"using {MIN_SEND_INTERVAL_SECONDS} instead."
)
send_interval = MIN_SEND_INTERVAL_SECONDS

addons = raw.get("enabled_addons", [])
if not isinstance(addons, list) or not all(isinstance(a, str) for a in addons):
raise ConfigError("'enabled_addons' string listesi olmalı")
raise ConfigError("'enabled_addons' must be a list of strings")

# Tanınmayan ad HATA DEĞİL, uyarıdır: yazım hatası yüzünden agent'ı
# başlatmamak, bir eklentinin toplanmamasından daha ağır bir sonuç olurdu.
Expand All @@ -167,8 +166,8 @@ def _parse(raw: dict, *, warn) -> Config:
unknown = [name for name in addons if name not in KNOWN_ADDONS]
if unknown:
warn(
f"enabled_addons içinde tanınmayan ad: {', '.join(unknown)} — "
f"yok sayılıyor. Geçerli değerler: {', '.join(KNOWN_ADDONS)}"
f"unknown name in enabled_addons: {', '.join(unknown)} — ignored. "
f"Valid values: {', '.join(KNOWN_ADDONS)}"
)

return Config(
Expand All @@ -180,7 +179,7 @@ def _parse(raw: dict, *, warn) -> Config:
flush_cpu_threshold=_positive_int(raw, "flush_cpu_threshold", 90),
flush_ram_threshold=_positive_int(raw, "flush_ram_threshold", 90),
flush_disk_threshold=_positive_int(raw, "flush_disk_threshold", 95),
flush_cooldown_seconds=_positive_int(raw, "flush_cooldown_seconds", 20),
flush_cooldown_seconds=_positive_int(raw, "flush_cooldown_seconds", 10),
spool_max_age_days=_positive_int(raw, "spool_max_age_days", 10),
spool_max_size_mb=_positive_int(raw, "spool_max_size_mb", 200),
enabled_addons=tuple(addons),
Expand Down Expand Up @@ -236,8 +235,8 @@ def load(self) -> Config:

except (OSError, tomllib.TOMLDecodeError, ConfigError) as exc:
if self._cached is None:
raise ConfigError(f"{self._path} okunamadı: {exc}") from exc
self._warn(f"config yeniden okunamadı ({exc}); önceki ayarlar sürüyor.")
raise ConfigError(f"could not read {self._path}: {exc}") from exc
self._warn(f"could not re-read config ({exc}); keeping the previous settings.")
return self._cached

self._cached = config
Expand Down
Loading
Loading