diff --git a/.gitignore b/.gitignore
index 7e7564d..6472923 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
@@ -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
diff --git a/README.md b/README.md
index 453b700..9a3c71b 100644
--- a/README.md
+++ b/README.md
@@ -1,190 +1,190 @@
# TraceBox
-**Uzaktan log-shipping ve monitoring** — başına ne geldiğini anlatacak kadar hayatta kalamayabilecek makineler için.
+**Remote log-shipping and monitoring** — for machines that may not survive long enough to tell you what happened to them.
-İzlenen her makinede küçük bir **agent** çalışır. Bu agent makinenin metriklerini (CPU, RAM, disk, ağ) ve system log'larını sürekli toplar ve makine çökmeden **önce** buluta gönderir. Makine erişilemez hale geldiğinde, çöküşe kadar olan olaylar çoktan başka bir yerdedir — herhangi bir tarayıcıdan okunabilir.
+A small **agent** runs on every monitored machine. It continuously collects the machine's metrics (CPU, RAM, disk, network) and system logs and ships them to the cloud **before** the machine goes down. By the time the machine becomes unreachable, the events leading up to the crash are already somewhere else — readable from any browser.
-Uçağın kara kutusu gibi: son ana kadar kaydeder ve çöküşün ulaşamayacağı yerde durur.
+Like an aircraft's black box: it records up to the last moment, and it sits where the crash cannot reach it.
---
-## Neden böyle çalışıyor?
+## Why it works this way
-Akla ilk gelen kurgu şudur: *"log'ları bulutta saklayalım."* Ama asıl sorun saklamak değil, **olay hâlâ olurken veriyi makinenin dışına taşımak.**
+The obvious design is *"let's store the logs in the cloud."* But the hard part is not storing them — it is **getting the data off the machine while the event is still happening.**
-Bir makine çöktüğü *sırada* son durumunu upload etmeye çalışıyorsa, iş zaten bitmiştir: arızalanan şey genellikle network stack'in, disk'in ya da process'in ta kendisidir. Yani "çökerken haber ver" mantığı, tam da haber verecek mekanizmanın bozulduğu anda devreye girer.
+If a machine tries to upload its final state *while* it is going down, it is already too late: the thing that is failing is usually the network stack, the disk, or the process itself. "Report it as you crash" kicks in at exactly the moment the reporting mechanism is broken.
-TraceBox bunu tersine çevirir:
+TraceBox inverts that:
-- **Sürekli ship eder.** Veri normal zamanlarda, hiçbir sorun yokken akmaya devam eder.
-- **Threshold aşılınca daha sıkı ship eder.** CPU %90'ı geçtiğinde sıradaki gönderim zamanı beklenmez; spool anında boşaltılır (emergency flush).
-- **Makine öldüğünde**, ilgilenilen veri çoktan dışarı çıkmıştır.
+- **It ships continuously.** Data keeps flowing in normal times, when nothing is wrong.
+- **It ships harder when a threshold is crossed.** Past 90% CPU it does not wait for the next scheduled send; the spool is emptied immediately (emergency flush).
+- **When the machine dies**, the data you care about is already outside.
---
-## Genel tablo
+## The big picture
```mermaid
flowchart LR
- subgraph machine["İzlenen makine"]
+ subgraph machine["Monitored machine"]
direction TB
agent["Agent Python + systemd"]
spool[("spool SQLite / disk")]
- agent -->|"her ölçüm önce diske"| spool
- spool -->|"batch olarak oku"| agent
+ agent -->|"every sample hits disk first"| spool
+ spool -->|"read as a batch"| agent
end
collector["Collector FastAPI @ Fly.io"]
postgres[("Postgres @ Supabase")]
dashboard["Dashboard Next.js"]
- user(["Kullanıcı tarayıcı"])
+ user(["User browser"])
agent ==>|"POST /ingest device key + TLS"| collector
collector ==>|"INSERT service key"| postgres
- collector -.->|"komut yanıtı pause · resume · delete"| agent
+ collector -.->|"command response pause · resume · delete"| agent
postgres -->|"SELECT user JWT + RLS"| dashboard
dashboard --> user
```
-Sistemde **iki ayrı yol** var ve bunlar bilerek hiç kesişmiyor:
+There are **two separate paths** in the system, and they deliberately never cross:
-| Yol | Kim | Nereden geçer | Hangi kimlikle |
+| Path | Who | Goes through | With which identity |
|---|---|---|---|
-| **WRITE** (yazma) | Agent | Collector üzerinden | `device key` — cihaz başına, tek tek revoke edilebilir |
-| **READ** (okuma) | Dashboard | Doğrudan Postgres'ten | `user JWT` — RLS ile satır bazında korunur |
+| **WRITE** | Agent | The collector | `device key` — one per host, revocable one by one |
+| **READ** | Dashboard | Postgres directly | `user JWT` — protected row by row through RLS |
-Agent database'e **asla doğrudan dokunmaz**; database'in service key'i **yalnızca collector'da** durur. Dashboard ise yazmaz, sadece okur.
+The agent **never touches the database directly**; the database's service key lives **only in the collector**. The dashboard does not write at all — it only reads.
---
-## Kullanılan teknolojiler
+## Technology used
-| Bileşen | Stack | Nerede çalışır | Rolü |
+| Component | Stack | Where it runs | Role |
|---|---|---|---|
-| **Agent** | Python 3.11+, `psutil`, `httpx`, SQLite | İzlenen makine, `systemd` service olarak | Toplar, disk'e spool eder, ship eder. Unprivileged bir kullanıcı ile çalışır. |
-| **Collector** | Python, FastAPI, Docker | Fly.io | Sistemin tek yazma kapısı. Cihaz kimliğini key hash'inden çözer. |
-| **Database** | PostgreSQL | Supabase | Depolama + Auth + Row-Level Security + `pg_cron` ile retention. |
-| **Dashboard** | Next.js, Tailwind CSS | Vercel | Salt-okunur pencere. Postgres ile doğrudan konuşur. |
+| **Agent** | Python 3.11+, `psutil`, `httpx`, SQLite | The monitored machine, as a `systemd` service | Collects, spools to disk, ships. Runs as an unprivileged user. |
+| **Collector** | Python, FastAPI, Docker | Fly.io | The system's only write gate. Resolves the host identity from a key hash. |
+| **Database** | PostgreSQL | Supabase | Storage + Auth + Row-Level Security + retention via `pg_cron`. |
+| **Dashboard** | Next.js, Tailwind CSS | Fly.io | A read-only window. Talks to Postgres directly. |
---
-## İstekler nereye gidiyor?
+## Where do the requests go?
-Collector'ın tüm endpoint'leri ve kimleri kabul ettiği:
+Every endpoint the collector exposes, and who it accepts:
-| Kim → Kime | Endpoint | Kimlik | Ne yapar |
+| Who → whom | Endpoint | Identity | What it does |
|---|---|---|---|
-| Agent → Collector | `POST /inventory` | device key | Makinenin künyesini `devices` satırının üzerine yazar |
-| Agent → Collector | `POST /ingest` | device key | metric / log / crash kayıtlarını insert eder, komutları ack'ler |
-| Agent → Collector | `GET /commands` | device key | Bekleyen `pause` / `resume` / `delete` komutlarını çeker |
-| Agent → Collector | `GET /verify` | device key | Kurulum sonrası bağlantı testi |
-| Dashboard → Collector | `POST /devices` | user JWT | Yeni cihaz oluşturur ve device key üretir (key bir kez gösterilir) |
-| Dashboard → Postgres | `SELECT` | user JWT | Collector'a hiç uğramaz; RLS korur |
+| Agent → Collector | `POST /inventory` | device key | Writes the machine's hardware profile over the `devices` row |
+| Agent → Collector | `POST /ingest` | device key | Inserts metric / log / crash records, acks commands |
+| Agent → Collector | `GET /commands` | device key | Pulls pending `pause` / `resume` / `delete` commands |
+| Agent → Collector | `GET /verify` | device key | Post-install connection test |
+| Dashboard → Collector | `POST /devices` | user JWT | Creates a host and generates a device key (shown once) |
+| Dashboard → Postgres | `SELECT` | user JWT | Never goes near the collector; RLS protects it |
---
-## Agent ne topluyor?
+## What does the agent collect?
-### Metrikler — varsayılan olarak 5 saniyede bir
+### Metrics — every 5 seconds by default
-| Alan | Ne ölçüyor |
+| Field | What it measures |
|---|---|
-| `cpu_percent` | Önceki ölçümden bu yana CPU kullanım yüzdesi |
-| `ram_used_mb` | Kullanımdaki RAM — `total − available` (cache gibi geri alınabilir alanlar düşülmüş) |
-| `disk_percent` | Kök dizinin (`/`) doluluk oranı |
-| `net_sent_mb` / `net_recv_mb` | Ağ trafiği — toplam bayt değil, **MB/s cinsinden hız** |
+| `cpu_percent` | CPU usage since the previous sample |
+| `ram_used_mb` | RAM in use — `total − available` (reclaimable space such as cache excluded) |
+| `disk_percent` | How full the root directory (`/`) is |
+| `net_sent_mb` / `net_recv_mb` | Network traffic — not total bytes but a **rate in MB/s**, loopback excluded |
-Hesaplanamayan bir alan (ilk ölçüm, reboot sonrası sıfırlanan sayaç) `0` değil **`null`** yazılır: "ölçemedim" ile "sıfırdı" birbirine karıştırılmaz.
+A field that cannot be computed (the first sample, a counter reset after a reboot) is written as **`null`**, not `0`: "I could not measure it" is never confused with "it was zero".
-**Opsiyonel add-on'lar** — `config.toml`'dan tek tek açılır, kapalıyken `null` kalır:
+**Optional add-ons** — switched on one by one in `config.toml`, `null` while off:
`temperature` · `swap` · `load_avg` · `gpu` · `external_ip` · `crash_processes`
-### Log'lar
+### Logs
-`journald`'dan okunur ve sabit bir şekle **normalize** edilir: `{ timestamp, level, message, source }`. Sadece 4 level var: `info` · `warning` · `error` · `critical`.
+Read from `journald` and **normalized** to a fixed shape: `{ timestamp, level, message, source }`. There are only 4 levels: `info` · `warning` · `error` · `critical`.
-Agent bir **cursor** tuttuğu için yeniden başlasa bile kaldığı yerden devam eder; log ne tekrarlanır ne atlanır. `journald`'a özgü kod tek bir dosyada izole edilmiştir (`logsources/`), böylece başka bir işletim sistemi desteği eklemek çekirdeği hiç değiştirmez.
+The agent keeps a **cursor**, so it picks up where it left off even after a restart; no log is repeated and none is skipped. The agent's own routine (`info`) lines are filtered out — a log shipper that ships its own chatter would spend most of its budget describing itself. Everything `journald`-specific is isolated in a single directory (`logsources/`), so adding support for another operating system does not touch the core.
-### Envanter (makinenin künyesi)
+### Inventory (the machine's profile)
-`cpu_model`, çekirdek sayıları, `arch`, `ram_total_mb`, `disk_total_mb`, `os_name` / `os_version`, `kernel_version`, `last_boot`, `agent_version`.
+`cpu_model`, core counts, `arch`, `ram_total_mb`, `disk_total_mb`, `os_name` / `os_version`, `kernel_version`, `last_boot`, `agent_version`.
-Bunlar nadiren değiştiği için zaman serisi olarak saklanmaz — açılışta okunur, bir öncekiyle karşılaştırılır ve **yalnızca değiştiyse** gönderilip `devices` satırının üzerine yazılır.
+These rarely change, so they are not stored as a time series — they are read at startup, compared against the previous reading, and sent (overwriting the `devices` row) **only if something changed**.
### Crash snapshot
-Bir threshold aşıldığı anda (emergency flush), `crash_processes` add-on'u açıksa en çok kaynak tüketen ilk 5 process kaydedilir. Böylece "makine neden boğuldu" sorusunun cevabı çöküşle birlikte kaybolmaz.
+The moment a threshold is crossed (emergency flush), if the `crash_processes` add-on is on, the 5 heaviest processes are recorded. That way the answer to "what choked the machine" does not disappear along with the machine.
-### Zamanlama — varsayılanlar
+### Timing — defaults
-| Ne | Ne sıklıkla |
+| What | How often |
|---|---|
-| Ölçüm toplama | 5 sn |
-| Buluta gönderim | 30 sn (kod bunu en az 10 sn ile sınırlar) |
-| Komut yoklama (poll) | 10 sn |
-| Emergency flush | CPU %90 · RAM %90 · disk %95 aşılınca — 20 sn cooldown ile |
-| Verinin saklanma süresi | 10 gün, sonra `pg_cron` siler |
+| Sampling | 5 s |
+| Shipping to the cloud | 10 s (the code floors this at 10 s) |
+| Command poll | 10 s |
+| Emergency flush | Past 90% CPU · 90% RAM · 95% disk — with a 10 s cooldown |
+| Data retention | 10 days, then `pg_cron` deletes it |
-Bu değerlerin hepsi makinedeki `config.toml`'dan yönetilir ve agent dosyayı her tick'te yeniden okur — **değişiklik için servisi yeniden başlatmak gerekmez.**
+All of these are managed from `config.toml` on the machine, and the agent re-reads that file on every tick — **no service restart is needed for a change to take effect.**
---
-## Veri modeli
+## Data model
```
-accounts (bir kullanıcı = bir account)
- └── devices (o account'a ait makineler + envanter + device key hash'i)
- ├── metrics ölçüm satırları
- ├── logs normalize edilmiş log satırları
- ├── crash_snapshots flush anındaki process listesi
- └── commands pause / resume / delete kuyruğu
+accounts (one user = one account)
+ └── devices (that account's machines + inventory + device key hash)
+ ├── metrics sample rows
+ ├── logs normalized log rows
+ ├── crash_snapshots the process list at flush time
+ └── commands the pause / resume / delete queue
```
-Her satır hem `device_id` hem `account_id` taşır. `account_id`'nin tekrarlanması (denormalization) bilinçlidir: RLS kuralı ve retention job'ı hiçbir `JOIN` yapmadan çalışabilsin diye.
+Every row carries both `device_id` and `account_id`. Repeating `account_id` (denormalization) is deliberate: it lets the RLS rule and the retention job work without a single `JOIN`.
-Bütün tablolarda **Row-Level Security** açık ve kural her yerde aynı: `account_id = auth.uid()`. Yani bir kullanıcı, kendi hesabına ait olmayan tek bir satırı bile göremez — bu kısıt uygulama kodunda değil, **database'in içinde** zorunlu kılınmıştır.
+**Row-Level Security** is on for every table and the rule is the same everywhere: `account_id = auth.uid()`. A user cannot see a single row that does not belong to their account — and that constraint is enforced **inside the database**, not in application code.
---
-## Öne çıkan tasarım kararları
+## Notable design decisions
-**Cihazlar kendi kimliğini kendisi söylemez.**
-Payload'ların içinde `device_id` yoktur. Agent sadece bir key sunar; collector bunu hash'leyip `devices.key_hash` ile eşleştirir ve hem `device_id`'yi hem `account_id`'yi kendisi türetir. Ele geçirilmiş bir agent başka bir hesabın verisine yazamaz — çünkü elinde o hesabı adlandıracak bir yol yoktur.
+**Hosts do not get to say who they are.**
+There is no `device_id` inside the payloads. The agent only presents a key; the collector hashes it, matches it against `devices.key_hash`, and derives both `device_id` and `account_id` itself. A compromised agent cannot write into another account's data — it has no way to name that account.
-**Service key collector'dan asla çıkmaz.**
-Supabase service key'i RLS'i bypass eder; onu izlenen her makineye dağıtmak, ele geçirilen tek bir makineyi bütün bir database breach'ine çevirirdi. Bunun yerine her cihaz kendine ait, tek tek iptal edilebilen bir key taşır.
+**The service key never leaves the collector.**
+The Supabase service key bypasses RLS; distributing it to every monitored machine would turn a single compromised host into a full database breach. Instead each host carries its own key, revocable one at a time.
**Single writer.**
-Her veri parçasının sahibi tam olarak tek bir bileşendir: `state.json`'ı yalnızca agent yazar; `last_seen`, `key_hash` ve komut durumunu yalnızca collector yazar. Bu kural teamül olarak bırakılmamış, column-level grant'lerle database seviyesinde zorunlu kılınmıştır.
+Every piece of data has exactly one owner: only the agent writes `state.json`; only the collector writes `last_seen`, `key_hash` and command status. This is not left to convention — it is enforced at the database level with column-level grants.
**At-least-once delivery + idempotency.**
-Agent her kaydı önce disk'teki spool'a yazar ve ancak `200` cevabını aldıktan sonra siler. Bu yüzden retry beklenen bir durumdur — dolayısıyla her kayıt agent'ın ürettiği bir `UUID` taşır ve server `ON CONFLICT DO NOTHING` ile insert eder. Sonuç: aynı kayıt iki kez gönderilse bile duplicate oluşmaz, veri kaybı içinse disk'in kendisinin arızalanması gerekir.
+The agent writes every record to the on-disk spool first and only deletes it after a `200`. Retries are therefore expected — which is why every record carries a UUID generated by the agent and the server inserts with `ON CONFLICT DO NOTHING`. The result: sending the same record twice produces no duplicate, and losing data would take the disk itself failing.
-**Pause, kaydı durdurmaz.**
-Bir cihazı pause etmek *upload*'ı durdurur, *toplamayı* değil. Veri yerelde birikmeye devam eder ve resume'da sırayla akar. Pause sırasında komut yoklaması da devam eder — aksi hâlde `resume` komutu cihaza hiçbir zaman ulaşamazdı. Komut ack'i de aynı sebeple durmaz: o bir telemetri değil kontrol mesajıdır ve tek bir ölçüm satırı taşımaz. Durdurulsaydı server komutun uygulandığını hiç öğrenemez, aynı `pause`u sonsuza kadar yeniden gönderir ve dashboard cihazı hâlâ "çalışıyor" gösterirdi.
+**Pause does not stop recording.**
+Pausing a host stops the *upload*, not the *collection*. Data keeps piling up locally and flows out in order on resume. Command polling continues during a pause too — otherwise a `resume` command could never reach the host. Command acks do not stop either, for the same reason: an ack is a control message, not telemetry, and carries no sample rows. If it stopped, the server would never learn the command was applied, would resend the same `pause` forever, and the dashboard would still show the host as running.
-**Silme işleminin bir sırası vardır.**
-Bir cihazı kaldırmak satırı hemen silmez. Önce kuyruğa bir `delete` komutu girer; agent komutu poll'da alır, **önce** ack'ler ve `200` cevabını gördükten sonra yerelini temizler. Collector satırı ancak o ack ile düşürür. Sıra her iki yönde de kritiktir: satır erken silinseydi key anında geçersizleşir ve agent kendisini kaldırması gerektiğini hiç öğrenemezdi; yerel temizlik ack'ten önce yapılsaydı key ile birlikte ack'i gönderme imkânı da giderdi ve satır sunucuda ölümsüz kalırdı.
+**Deletion has an order.**
+Removing a host does not delete the row right away. First a `delete` command enters the queue; the agent picks it up on a poll, acks it **first**, and cleans up locally only after seeing the `200`. The collector drops the row on that ack. The order matters in both directions: had the row been deleted early, the key would be invalid instantly and the agent would never learn it should remove itself; had the local wipe run before the ack, the key would be gone along with any chance of sending that ack, and the row would live forever on the server.
-Temizliğin ikinci yarısı ise agent'ın yetkisi **dışındadır**: servis yetkisiz bir kullanıcıyla, `NoNewPrivileges=yes` ve `ProtectSystem=strict` altında çalışır — kendi kurulumunu kaldıramaz, systemd'ye dokunamaz. Bu yüzden agent yalnızca yazabildiği tek yere, kendi state dizinine bir işaret dosyası bırakır; root tarafında bekleyen bir systemd `path` unit'i onu görür ve `uninstall.sh`'i çalıştırır. Böylece delete uçtan uca tamamlanır ama agent'ın yetkisi bir gram artmaz.
+The second half of that cleanup is **outside** the agent's privileges: the service runs as an unprivileged user under `NoNewPrivileges=yes` and `ProtectSystem=strict` — it cannot remove its own installation and cannot touch systemd. So the agent drops a marker file in the only place it can write, its own state directory; a systemd `path` unit waiting on the root side sees it and runs `uninstall.sh`. Delete completes end to end without the agent gaining a gram of privilege.
-**Agent'ın sınırları vardır.**
-Disk'teki spool hem yaş (10 gün) hem boyut (200 MB) ile sınırlanmış bir ring buffer'dır; sınır aşılınca en eski kayıt düşer. İzlediği makinenin disk'ini dolduran bir monitoring aracı, açıklaması beklenen outage'a kendisi sebep olmuş olur.
+**The agent has limits.**
+The on-disk spool is a ring buffer bounded by both age (10 days) and size (200 MB); past either limit the oldest record is dropped. A monitoring tool that fills up the disk of the machine it watches has caused the very outage it was supposed to explain.
---
-## Repo yapısı
+## Repository layout
```
-agent/ Python agent — izlenen makinede çalışır
- core/ platform-bağımsız: loop, config, state, metrics, spool, shipper
- logsources/ OS'a özgü log okuyucular, ortak bir interface arkasında
-collector/ FastAPI service @ Fly.io — sistemin tek yazma kapısı
-dashboard/ Next.js okuma arayüzü
-db/ şema, trigger'lar, row-level security, retention
+agent/ Python agent — runs on the monitored machine
+ core/ platform-independent: loop, config, state, metrics, spool, shipper
+ logsources/ OS-specific log readers, behind a shared interface
+collector/ FastAPI service @ Fly.io — the system's only write gate
+dashboard/ Next.js read interface
+db/ schema, triggers, row-level security, retention
```
-## Collector'ı yerelde çalıştırma
+## Running the collector locally
```bash
cd collector
@@ -194,21 +194,54 @@ uvicorn main:app --reload --port 8080
curl localhost:8080/health
```
-## Database kurulumu
+## Running the dashboard locally
-Bir Supabase projesine karşı, şu sırayla çalıştır:
+```bash
+cd dashboard
+cp .env.example .env.local # fill in the Supabase URL, anon key and collector URL
+npm install
+npm run dev # http://localhost:3000
+```
+
+## Deploying the dashboard
+
+The `NEXT_PUBLIC_*` values are **baked into the JavaScript at build time**, so they cannot be Fly
+secrets — a secret only exists at runtime, long after the bundle is printed. They have to reach the
+Docker build as `--build-arg`:
+
+```bash
+cd dashboard
+fly deploy \
+ --build-arg NEXT_PUBLIC_SUPABASE_URL=https://.supabase.co \
+ --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_... \
+ --build-arg NEXT_PUBLIC_COLLECTOR_URL=https://.fly.dev
+```
+
+Or, to avoid retyping them, straight from the file the dev server already uses:
+
+```bash
+fly deploy $(grep -E '^NEXT_PUBLIC_' .env.local | sed 's/^/--build-arg /')
+```
+
+`fly.toml` leaves those three build args **empty on purpose**. An example value there would let a
+bare `fly deploy` succeed and ship an image that looks healthy but can never reach Supabase; empty
+values make the build stop instead.
+
+## Database setup
+
+Against a Supabase project, run these in order:
```
db/schema.sql → db/triggers.sql → db/rls.sql
```
-Sıra önemli: trigger'lar `accounts` tablosuna referans verir, policy'ler de her tabloya.
+The order matters: the triggers reference the `accounts` table, and the policies reference every table.
---
-## Durum
+## Status
-Aktif geliştirme aşamasında. Proje **vertical slice**'lar hâlinde inşa ediliyor — her adım yatay bir katman değil, uçtan uca çalışan ince bir yol.
+Under active development. The project is built in **vertical slices** — each step is a thin path that works end to end, not a horizontal layer.
---
@@ -217,9 +250,3 @@ Aktif geliştirme aşamasında. Proje **vertical slice**'lar hâlinde inşa edil
TraceBox is licensed under the TraceBox License v1.0.
See [LICENSE](./LICENSE) for the complete license terms.
-
-## Lisans
-
-TraceBox, TraceBox License v1.0 ile lisanslanmıştır.
-
-Lisansın tam şartları için [LICENSE](./LICENSE) dosyasına bakınız.
diff --git a/agent/__main__.py b/agent/__main__.py
index 884f3a3..e197023 100644
--- a/agent/__main__.py
+++ b/agent/__main__.py
@@ -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
"""
@@ -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]:
@@ -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
@@ -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
@@ -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
diff --git a/agent/config.example.toml b/agent/config.example.toml
index 78194ac..71f4cdf 100644
--- a/agent/config.example.toml
+++ b/agent/config.example.toml
@@ -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 = []
diff --git a/agent/core/commands.py b/agent/core/commands.py
index 6b1f1a0..f5418ed 100644
--- a/agent/core/commands.py
+++ b/agent/core/commands.py
@@ -82,10 +82,10 @@ 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}")
@@ -93,7 +93,7 @@ def fetch(self, config) -> list[Command]:
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)
@@ -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"]:
@@ -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.
@@ -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)
@@ -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)
@@ -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 received — sending 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
@@ -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
diff --git a/agent/core/config.py b/agent/core/config.py
index f2a703f..6324612 100644
--- a/agent/core/config.py
+++ b/agent/core/config.py
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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(
@@ -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),
@@ -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
diff --git a/agent/core/loop.py b/agent/core/loop.py
index 61d8486..6bec4c1 100644
--- a/agent/core/loop.py
+++ b/agent/core/loop.py
@@ -68,7 +68,7 @@ def _install_stop_signal() -> threading.Event:
stop = threading.Event()
def handle(signum, _frame) -> None:
- _log(f"[signal] {signal.Signals(signum).name} alındı, döngü kapanıyor.")
+ _log(f"[signal] {signal.Signals(signum).name} received, shutting down the loop.")
stop.set()
signal.signal(signal.SIGTERM, handle)
@@ -99,23 +99,23 @@ def _startup_inventory(config: Config, state: State) -> Inventory | None:
"""
current = inventory_module.collect_inventory(config)
_log(
- f"[start] envanter: {current.os_name} {current.os_version} · "
+ f"[start] inventory: {current.os_name} {current.os_version} · "
f"{current.cpu_model} · "
- f"{current.cpu_cores_physical}/{current.cpu_cores_logical} çekirdek · "
+ f"{current.cpu_cores_physical}/{current.cpu_cores_logical} cores · "
f"{current.ram_total_mb}MB RAM · {current.disk_total_mb}MB disk · "
f"kernel {current.kernel_version} ({current.arch})"
)
- _log(f"[start] açılış zamanı: {current.last_boot}")
+ _log(f"[start] booted at: {current.last_boot}")
changed = inventory_module.changed_fields(current, state.known_inventory)
if not changed:
- _log("[start] envanter değişmemiş — gönderim gerekmiyor.")
+ _log("[start] inventory unchanged — nothing to send.")
return None
- reason = "ilk kez okundu" if not state.known_inventory else "değişti"
+ reason = "read for the first time" if not state.known_inventory else "changed"
_log(
- f"[start] envanter {reason}: {len(changed)} alan "
- f"({', '.join(sorted(changed))}) — gönderilecek"
+ f"[start] inventory {reason}: {len(changed)} field(s) "
+ f"({', '.join(sorted(changed))}) — will be sent"
)
return current
@@ -182,7 +182,7 @@ def _collect_logs(source: LogSource, spool: Spool, state: State, store: StateSto
except LogSourceError as error:
# Log kaynağı erişilemez diye metrik toplama ve gönderim durmaz;
# tur log'suz sürer, sorun bir sonraki turda yeniden denenir.
- _log(f"[logs] okunamadı: {error}")
+ _log(f"[logs] could not read: {error}")
return 0
for record in records:
@@ -193,7 +193,7 @@ def _collect_logs(source: LogSource, spool: Spool, state: State, store: StateSto
store.save(state)
if records:
- _log(f"[logs] {len(records)} kayıt ({_level_summary(records)})")
+ _log(f"[logs] {len(records)} record(s) ({_level_summary(records)})")
return sum(1 for record in records if record.level in URGENT_LEVELS)
@@ -232,7 +232,7 @@ def _poll_commands(
except CommandError as error:
# Komut alınamaması toplamayı ve gönderimi durdurmaz; tur komutsuz
# geçer, sorun bir sonraki poll'da yeniden denenir.
- _log(f"[poll] komutlar alınamadı: {error}")
+ _log(f"[poll] could not fetch commands: {error}")
return False
if not commands:
@@ -276,12 +276,12 @@ def _send_inventory(
"""Envanteri gönderir; 200 alınırsa state'e işler ve None döndürür."""
result = shipper.send_inventory(config, pending.as_dict())
if not result.ok:
- _log(f"[send] envanter gönderilemedi: {result.detail}")
+ _log(f"[send] could not send inventory: {result.detail}")
return pending
state.known_inventory = pending.as_dict()
store.save(state)
- _log("[send] envanter gönderildi.")
+ _log("[send] inventory sent.")
return None
@@ -297,8 +297,8 @@ def _send_spool(
if not result.ok:
_log(
- f"[send] gönderilemedi: {result.detail} — {spool.count()} kayıt bekliyor, "
- f"{shipper.backoff_seconds:.0f} sn sonra tekrar denenecek."
+ f"[send] failed: {result.detail} — {spool.count()} record(s) waiting, "
+ f"retrying in {shipper.backoff_seconds:.0f}s."
)
return
@@ -306,7 +306,7 @@ def _send_spool(
state.last_send = utc_now_iso()
store.save(state)
- _log(f"[send] {result.sent} kayıt gönderildi (spool: {spool.count()}).")
+ _log(f"[send] {result.sent} record(s) sent (spool: {spool.count()}).")
def _maybe_flush(
@@ -346,7 +346,7 @@ def _maybe_flush(
# Veri kaybolmaz: eşiği aşan ölçüm de, tetikleyen log da spool'da
# duruyor ve normal gönderim turunda çıkacak. Bastırılan tek şey
# ACELE etmek — cooldown'ın amacı zaten flush selini önlemek.
- _log(f"[flush] {reason} eşiği aşıldı, cooldown sürüyor — atlandı.")
+ _log(f"[flush] {reason} threshold exceeded, cooldown active — skipped.")
return False
# SIRA ÖNEMLİDİR: snapshot önce spool'a yazılır, sonra gönderim yapılır.
@@ -363,12 +363,12 @@ def _maybe_flush(
if not shipper.ready():
_log(
- f"[flush] {reason} eşiği aşıldı — backoff sürüyor, "
- f"{shipper.backoff_seconds:.0f} sn sonra gönderilecek."
+ f"[flush] {reason} threshold exceeded — backoff active, "
+ f"sending in {shipper.backoff_seconds:.0f}s."
)
return False
- _log(f"[flush] {reason} eşiği aşıldı — acil gönderim.")
+ _log(f"[flush] {reason} threshold exceeded — emergency ship.")
_send_spool(shipper, config, state, store, spool)
return True
@@ -396,22 +396,22 @@ def run(loader: ConfigLoader, store: StateStore, log_source: LogSource) -> None:
_log(f"[start] TraceBox agent {__version__}")
_log(f"[start] config: {loader.path}")
_log(f"[start] state: {store.path}")
- _log(f"[start] spool: {spool.path} ({spool.count()} bekleyen kayıt)")
- _log(f"[start] hedef: {config.collector_url}")
+ _log(f"[start] spool: {spool.path} ({spool.count()} record(s) waiting)")
+ _log(f"[start] target: {config.collector_url}")
_log(
- "[start] aralıklar: "
+ "[start] intervals: "
f"collect={config.collect_interval_seconds}s "
f"send={config.send_interval_seconds}s "
f"poll={config.command_poll_seconds}s (tick={TICK_SECONDS}s)"
)
_log(f"[start] logging_enabled={state.logging_enabled}")
_log(
- "[start] eklentiler: "
- + (", ".join(config.enabled_addons) if config.enabled_addons else "yok (yalnızca çekirdek)")
+ "[start] add-ons: "
+ + (", ".join(config.enabled_addons) if config.enabled_addons else "none (core only)")
)
_log(
"[start] journal cursor: "
- + ("kayıtlı — kaldığı yerden" if state.journal_cursor else "yok — şimdiden başlanacak")
+ + ("stored — resuming" if state.journal_cursor else "none — starting from now")
)
pending_inventory = _startup_inventory(config, state)
@@ -480,6 +480,6 @@ def run(loader: ConfigLoader, store: StateStore, log_source: LogSource) -> None:
spool.close()
if deleted:
- _log("[stop] cihaz silindi — agent duruyor, systemd yeniden başlatmayacak.")
+ _log("[stop] host deleted — agent stopping, systemd will not restart it.")
else:
- _log("[stop] döngü durdu.")
+ _log("[stop] loop stopped.")
diff --git a/agent/core/metrics.py b/agent/core/metrics.py
index dd951a1..457452b 100644
--- a/agent/core/metrics.py
+++ b/agent/core/metrics.py
@@ -188,14 +188,32 @@ def _network_rates(self) -> tuple[float | None, float | None]:
* ilk ölçüm — karşılaştırılacak önceki sayaç yok,
* sayacın geriye gitmesi — makine yeniden başlamış ve sayaç sıfırlanmış
demektir; fark negatif çıkar ve anlamsızdır.
+
+ LOOPBACK HARİÇ (`lo`). psutil.net_io_counters() varsayılan olarak tüm
+ arayüzleri toplar ve loopback da bunlara dâhildir; oysa `lo` üzerindeki
+ trafik makinenin kendi içinde kalır, ağa hiç çıkmaz. Aynı bayt hem
+ gönderilen hem alınan olarak sayıldığı için ölçüm iki kez şişerdi:
+ yerel bir veritabanına konuşan bir uygulama, ağ kartı boşken bile
+ grafikte megabitler gösterirdi. Kullanıcının sorusu "bu makine ağı ne
+ kadar kullanıyor" — cevabın içine makinenin kendi kendine konuşması
+ girmemeli.
"""
- counters = psutil.net_io_counters()
+ totals = self._external_bytes()
now = time.monotonic()
previous = self._previous_net
+ if totals is None:
+ # Arayüz listesi okunamadı. Sayaç DA sıfırlanır: eski tabanı
+ # saklasaydık, okuma geri geldiğinde arada geçen tüm süre tek bir
+ # örneğe sıkışır ve sahte bir sıçrama olarak çizilirdi.
+ self._previous_net = None
+ return None, None
+
+ sent, recv = totals
+
# Sayaçlar her durumda güncellenir: hesap yapılamayan bir ölçüm bile
# bir SONRAKİ ölçümün tabanı olur.
- self._previous_net = (counters.bytes_sent, counters.bytes_recv, now)
+ self._previous_net = (sent, recv, now)
if previous is None:
return None, None
@@ -204,13 +222,41 @@ def _network_rates(self) -> tuple[float | None, float | None]:
elapsed = now - previous_time
if elapsed <= 0:
return None, None
- if counters.bytes_sent < previous_sent or counters.bytes_recv < previous_recv:
+ if sent < previous_sent or recv < previous_recv:
return None, None
- sent_rate = (counters.bytes_sent - previous_sent) / BYTES_PER_MB / elapsed
- recv_rate = (counters.bytes_recv - previous_recv) / BYTES_PER_MB / elapsed
+ sent_rate = (sent - previous_sent) / BYTES_PER_MB / elapsed
+ recv_rate = (recv - previous_recv) / BYTES_PER_MB / elapsed
return round(sent_rate, 3), round(recv_rate, 3)
+ @staticmethod
+ def _external_bytes() -> tuple[int, int] | None:
+ """Loopback dışındaki arayüzlerin toplam gönderilen/alınan baytı.
+
+ Arayüz adı `lo` ile başlıyorsa atlanır: Linux'ta `lo`, ağ ad alanı
+ kullanan kurulumlarda `lo0`/`lo1` da görülebilir. Ad üzerinden eleme
+ kaba bir ölçüt ama psutil arayüzün türünü söylemiyor; alternatif,
+ her platform için ayrı bir sistem çağrısı yazmak olurdu.
+
+ Sayaç geri toplandığı için, ARAYÜZ SAYISI DEĞİŞİRSE (bir VPN kalkar,
+ bir kapsayıcı köprüsü inerse) toplam geriye gidebilir. Bu, çağıranın
+ zaten ele aldığı "sayaç geriye gitti" durumuna düşer: o örnek atlanır,
+ bir sonraki yeni tabandan hesaplanır.
+ """
+ try:
+ per_nic = psutil.net_io_counters(pernic=True)
+ except (OSError, RuntimeError):
+ return None
+
+ sent = 0
+ recv = 0
+ for name, counters in per_nic.items():
+ if name.lower().startswith("lo"):
+ continue
+ sent += counters.bytes_sent
+ recv += counters.bytes_recv
+ return sent, recv
+
def _cpu_temperature() -> float | None:
"""CPU sıcaklığı (°C) — bilinen sensörlerden ilk bulunan.
diff --git a/agent/core/shipper.py b/agent/core/shipper.py
index 0b748ea..55924b1 100644
--- a/agent/core/shipper.py
+++ b/agent/core/shipper.py
@@ -140,14 +140,14 @@ def _post(self, config, path: str, payload: dict) -> tuple[bool, str]:
try:
response = self._client.post(url, json=payload, headers=headers)
except httpx.HTTPError as error:
- return self._failure(f"bağlanılamadı ({error.__class__.__name__})")
+ return self._failure(f"could not connect ({error.__class__.__name__})")
if response.status_code == 200:
self._success()
return True, ""
if response.status_code == 401:
- return self._failure("cihaz anahtarı reddedildi (401)")
+ return self._failure("device key rejected (401)")
return self._failure(f"HTTP {response.status_code}")
diff --git a/agent/core/state.py b/agent/core/state.py
index 1bd682b..97a5425 100644
--- a/agent/core/state.py
+++ b/agent/core/state.py
@@ -103,7 +103,7 @@ def load(self) -> State:
with self._path.open("r", encoding="utf-8") as handle:
raw = json.load(handle)
if not isinstance(raw, dict):
- raise ValueError("state.json bir JSON nesnesi değil")
+ raise ValueError("state.json is not a JSON object")
except (OSError, ValueError) as exc:
self._quarantine(exc)
return State()
@@ -163,7 +163,7 @@ def mark_deleted(self) -> Path:
"""
self._dir.mkdir(parents=True, exist_ok=True)
self.deleted_marker_path.write_text(
- f"{utc_now_iso()} delete komutu uygulandı\n", encoding="utf-8"
+ f"{utc_now_iso()} delete command applied\n", encoding="utf-8"
)
return self.deleted_marker_path
@@ -176,9 +176,9 @@ def _quarantine(self, exc: Exception) -> None:
quarantine_path = self._path.with_name(f"{STATE_FILENAME}.corrupt")
try:
os.replace(self._path, quarantine_path)
- self._warn(f"state.json okunamadı ({exc}); {quarantine_path} olarak saklandı.")
+ self._warn(f"could not read state.json ({exc}); kept as {quarantine_path}.")
except OSError as move_error:
- self._warn(f"state.json okunamadı ({exc}) ve taşınamadı ({move_error}).")
+ self._warn(f"could not read state.json ({exc}) and could not move it ({move_error}).")
class SingleWriterLock:
@@ -209,7 +209,7 @@ def __enter__(self) -> SingleWriterLock:
except OSError:
os.close(fd)
raise RuntimeError(
- f"başka bir agent süreci çalışıyor (kilit: {self._path})"
+ f"another agent process is running (lock: {self._path})"
) from None
os.write(fd, f"{os.getpid()}\n".encode())
diff --git a/agent/core/verify.py b/agent/core/verify.py
index b9e8d94..f35fd27 100644
--- a/agent/core/verify.py
+++ b/agent/core/verify.py
@@ -50,8 +50,8 @@ def verify(config) -> VerifyResult:
return VerifyResult(
ok=False,
detail=(
- f"collector'a ulaşılamadı ({type(error).__name__}) — "
- f"collector_url doğru mu, makinenin internet erişimi var mı?"
+ f"could not reach the collector ({type(error).__name__}) — "
+ f"is collector_url correct, and does this machine have internet access?"
),
)
@@ -62,14 +62,14 @@ def verify(config) -> VerifyResult:
return VerifyResult(
ok=False,
detail=(
- "cihaz anahtarı reddedildi (401) — config.toml'daki device_key, "
- "dashboard'un verdiği anahtarla aynı mı?"
+ "device key rejected (401) — is device_key in config.toml the same "
+ "key the dashboard gave you?"
),
)
return VerifyResult(
ok=False,
- detail=f"collector beklenmeyen yanıt verdi (HTTP {response.status_code})",
+ detail=f"the collector returned an unexpected response (HTTP {response.status_code})",
)
@@ -82,11 +82,11 @@ def _describe(response: httpx.Response) -> str:
try:
body = response.json()
except ValueError:
- return "bağlantı kuruldu"
+ return "connection established"
if not isinstance(body, dict):
- return "bağlantı kuruldu"
+ return "connection established"
device_name = body.get("device_name") or "?"
version = body.get("version") or "?"
- return f"cihaz: {device_name} · collector sürümü: {version}"
+ return f"host: {device_name} · collector version: {version}"
diff --git a/agent/install.sh b/agent/install.sh
index 49f78ae..78aef8e 100755
--- a/agent/install.sh
+++ b/agent/install.sh
@@ -63,22 +63,22 @@ add_rollback() { ROLLBACK+=("$1"); }
run_rollback() {
(( ROLLBACK_ENABLED )) || return 0
(( ${#ROLLBACK[@]} )) || return 0
- printf '\n yarım kalan kurulum geri alınıyor...\n' >&2
+ printf '\n rolling back the unfinished installation...\n' >&2
local i
for (( i = ${#ROLLBACK[@]} - 1; i >= 0; i-- )); do
eval "${ROLLBACK[i]}" >/dev/null 2>&1 || true
done
- printf ' sistem kurulum öncesi haline döndürüldü.\n' >&2
+ printf ' the system is back to its pre-install state.\n' >&2
}
fail() {
printf '\n✗ %s\n' "$*" >&2
run_rollback
- (( ROLLBACK_ENABLED )) || printf '\n Yapılandırma korundu. Kaldırmak için: sudo %s/uninstall.sh\n' "${INSTALL_DIR}" >&2
+ (( ROLLBACK_ENABLED )) || printf '\n The configuration was kept. To remove it: sudo %s/uninstall.sh\n' "${INSTALL_DIR}" >&2
exit 1
}
-on_error() { fail "Kurulum ${1} numaralı satırda başarısız oldu."; }
+on_error() { fail "Installation failed on line ${1}."; }
trap 'on_error ${LINENO}' ERR
# İndirme için açılan geçici dizin her durumda silinir.
@@ -103,16 +103,16 @@ prompt_secret() {
# -s: yazılan karakterler ekranda görünmez (omuz üstünden okunmasın).
read -r -s value < "${TTY_DEVICE}"
printf '\n' > "${TTY_DEVICE}"
- [[ -n "${value}" ]] || printf ' boş olamaz, tekrar deneyin\n' > "${TTY_DEVICE}"
+ [[ -n "${value}" ]] || printf ' cannot be empty, try again\n' > "${TTY_DEVICE}"
done
printf '%s' "${value}"
}
ask_yes_no() {
local answer=""
- printf ' %s [e/H] ' "$1" > "${TTY_DEVICE}"
+ printf ' %s [y/N] ' "$1" > "${TTY_DEVICE}"
read -r answer < "${TTY_DEVICE}"
- [[ "${answer}" == "e" || "${answer}" == "E" ]]
+ [[ "${answer}" == "y" || "${answer}" == "Y" ]]
}
run_as_service_user() {
@@ -123,51 +123,51 @@ run_as_service_user() {
fi
}
-printf '\nTraceBox Agent kurulumu\n'
+printf '\nTraceBox Agent installer\n'
# ===========================================================================
# 1/7 Ön kontrol — eksik bir şey varsa HİÇBİR ŞEY oluşturmadan dur.
# ===========================================================================
-step "1/7 Ön kontrol"
+step "1/7 Preflight checks"
-[[ ${EUID} -eq 0 ]] || fail "root yetkisi gerekli. Şöyle çalıştırın: sudo bash install.sh"
-[[ "$(uname -s)" == "Linux" ]] || fail "TraceBox Agent yalnızca Linux'ta çalışır (bulunan: $(uname -s))."
+[[ ${EUID} -eq 0 ]] || fail "Root privileges are required. Run it like this: sudo bash install.sh"
+[[ "$(uname -s)" == "Linux" ]] || fail "TraceBox Agent runs on Linux only (found: $(uname -s))."
# systemd'nin init olarak çalıştığının standart göstergesi bu dizindir.
-[[ -d /run/systemd/system ]] || fail "systemd bulunamadı; agent bir systemd servisi olarak çalışır."
-have systemctl || fail "systemctl bulunamadı."
-have journalctl || fail "journalctl bulunamadı; agent sistem loglarını journald'dan okur."
-have useradd || fail "useradd bulunamadı; yetkisiz servis kullanıcısı oluşturulamaz."
-have tar || fail "tar bulunamadı; kaynak arşivi açılamaz."
-[[ -e "${TTY_DEVICE}" ]] || fail "Terminal erişimi yok. Cihaz anahtarı sorularak alınır; betiği bir terminalden çalıştırın."
+[[ -d /run/systemd/system ]] || fail "systemd not found; the agent runs as a systemd service."
+have systemctl || fail "systemctl not found."
+have journalctl || fail "journalctl not found; the agent reads system logs from journald."
+have useradd || fail "useradd not found; the unprivileged service user cannot be created."
+have tar || fail "tar not found; the source archive cannot be extracted."
+[[ -e "${TTY_DEVICE}" ]] || fail "No terminal access. The device key is asked for interactively; run the script from a terminal."
if have curl; then
DOWNLOADER="curl"
elif have wget; then
DOWNLOADER="wget"
else
- fail "curl veya wget gerekli; agent kaynağı indirilemez."
+ fail "curl or wget is required; the agent source cannot be downloaded."
fi
-have python3 || fail "python3 bulunamadı; en az 3.11 gerekli."
+have python3 || fail "python3 not found; 3.11 or newer is required."
python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' \
- || fail "python3 sürümü çok eski ($(python3 -V 2>&1)); en az 3.11 gerekli."
+ || fail "This python3 is too old ($(python3 -V 2>&1)); 3.11 or newer is required."
# venv ve ensurepip Debian/Ubuntu'da ayrı bir pakettedir; eksikse izole ortam
# kurulamaz ve bu ancak 4. adımda patlardı.
python3 -c 'import venv, ensurepip' >/dev/null 2>&1 \
- || fail "python3 venv modülü eksik. Debian/Ubuntu'da: sudo apt install python3-venv"
+ || fail "The python3 venv module is missing. On Debian/Ubuntu: sudo apt install python3-venv"
if [[ -d "${INSTALL_DIR}" || -e "${UNIT_PATH}" ]]; then
- say "mevcut kurulum bulundu — kod ve servis yenilenecek"
+ say "existing installation found — the code and the service will be refreshed"
fi
-say "ortam uygun ($(python3 -V 2>&1), ${DOWNLOADER})"
+say "environment is suitable ($(python3 -V 2>&1), ${DOWNLOADER})"
# ===========================================================================
# 2/7 Kaynak — betik tek başına indirildiği için agent kodunu kendisi çeker.
# ===========================================================================
-step "2/7 Agent kaynağı indiriliyor"
+step "2/7 Downloading the agent source"
WORK_DIR="$(mktemp -d)"
TARBALL="${WORK_DIR}/source.tar.gz"
@@ -178,18 +178,18 @@ case "${DOWNLOADER}" in
curl)
# --proto '=https': yönlendirme düz HTTP'ye düşerse indirme reddedilir.
curl -fsSL --proto '=https' --tlsv1.2 -o "${TARBALL}" "${SOURCE_URL}" \
- || fail "Kaynak indirilemedi: ${SOURCE_URL}"
+ || fail "Could not download the source: ${SOURCE_URL}"
;;
wget)
wget -q --https-only -O "${TARBALL}" "${SOURCE_URL}" \
- || fail "Kaynak indirilemedi: ${SOURCE_URL}"
+ || fail "Could not download the source: ${SOURCE_URL}"
;;
esac
mkdir -p "${SOURCE_ROOT}"
# --strip-components=1: arşivin en üstündeki "TraceBox-master/" sarmalı atılır.
tar -xzf "${TARBALL}" -C "${SOURCE_ROOT}" --strip-components=1 \
- || fail "Kaynak arşivi açılamadı."
+ || fail "Could not extract the source archive."
SOURCE_AGENT="${SOURCE_ROOT}/agent"
for required in \
@@ -197,18 +197,18 @@ for required in \
"tracebox-agent.service" "tracebox-uninstall.service" "tracebox-uninstall.path"
do
[[ -f "${SOURCE_AGENT}/${required}" ]] \
- || fail "İndirilen arşiv eksik: agent/${required} yok."
+ || fail "The downloaded archive is incomplete: agent/${required} is missing."
done
-say "indirildi ve açıldı"
+say "downloaded and extracted"
# ===========================================================================
# 3/7 Kullanıcı, dizinler ve kodun yerleştirilmesi
# ===========================================================================
-step "3/7 Kullanıcı ve dizinler"
+step "3/7 User and directories"
if id "${SERVICE_USER}" >/dev/null 2>&1; then
- say "kullanıcı zaten var: ${SERVICE_USER}"
+ say "user already exists: ${SERVICE_USER}"
else
NOLOGIN_SHELL="$(command -v nologin || true)"
[[ -n "${NOLOGIN_SHELL}" ]] || NOLOGIN_SHELL="/bin/false"
@@ -216,22 +216,22 @@ else
# ev dizini yok. Bu hesapla oturum açılamaz, yalnızca servis çalışır.
useradd --system --no-create-home --shell "${NOLOGIN_SHELL}" "${SERVICE_USER}"
add_rollback "userdel ${SERVICE_USER}"
- say "yetkisiz kullanıcı oluşturuldu: ${SERVICE_USER}"
+ say "unprivileged user created: ${SERVICE_USER}"
fi
# journald'ı okuyabilmek için gereken TEK ek yetki. root değil, grup üyeliği.
if getent group systemd-journal >/dev/null 2>&1; then
usermod -aG systemd-journal "${SERVICE_USER}"
- say "systemd-journal grubuna eklendi (log okuma izni)"
+ say "added to the systemd-journal group (log read permission)"
else
- warn "systemd-journal grubu yok; agent yalnızca kendi loglarını görebilir"
+ warn "no systemd-journal group; the agent can only see its own logs"
fi
for dir in "${INSTALL_DIR}" "${CONFIG_DIR}" "${STATE_DIR}"; do
if [[ ! -d "${dir}" ]]; then
mkdir -p "${dir}"
add_rollback "rm -rf ${dir}"
- say "oluşturuldu: ${dir}"
+ say "created: ${dir}"
fi
done
@@ -240,7 +240,7 @@ done
# kendini kaldırır.
if [[ -e "${DELETED_MARKER}" ]]; then
rm -f "${DELETED_MARKER}"
- say "önceki kurulumdan kalan kaldırma işareti silindi"
+ say "removal marker left from a previous installation deleted"
fi
# Kod her kurulumda sıfırdan kopyalanır: eski sürümden kalan bir dosya
@@ -254,23 +254,23 @@ rm -f "${INSTALL_DIR}/agent/install.sh" "${INSTALL_DIR}/agent/uninstall.sh"
# Kaldırma betiği bilinen bir yere konur: kullanıcı elle çalıştırabilsin.
if [[ -f "${SOURCE_AGENT}/uninstall.sh" ]]; then
install -m 755 "${SOURCE_AGENT}/uninstall.sh" "${INSTALL_DIR}/uninstall.sh"
- say "kaldırma betiği: ${INSTALL_DIR}/uninstall.sh"
+ say "uninstall script: ${INSTALL_DIR}/uninstall.sh"
fi
-say "kod yerleştirildi: ${INSTALL_DIR}/agent"
+say "code installed: ${INSTALL_DIR}/agent"
# ===========================================================================
# 4/7 İzole Python ortamı — sistem Python'ına hiç dokunulmaz.
# ===========================================================================
-step "4/7 İzole Python ortamı"
+step "4/7 Isolated Python environment"
rm -rf "${VENV_DIR}"
-python3 -m venv "${VENV_DIR}" || fail "Sanal ortam oluşturulamadı: ${VENV_DIR}"
+python3 -m venv "${VENV_DIR}" || fail "Could not create the virtual environment: ${VENV_DIR}"
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 \
- || warn "pip güncellenemedi; mevcut sürümle devam ediliyor"
+ || warn "could not upgrade pip; continuing with the current version"
"${VENV_DIR}/bin/pip" install --quiet -r "${INSTALL_DIR}/agent/requirements.txt" \
- || fail "Bağımlılıklar kurulamadı (ağ erişimi var mı?)."
+ || fail "Could not install the dependencies (is there network access?)."
# Bytecode şimdi, root iken üretilir: /opt/tracebox agent'a salt-okunur olduğu
# için servis her açılışta yeniden derlemeye çalışıp başarısız olmasın.
@@ -288,28 +288,28 @@ chmod 750 "${STATE_DIR}"
chown root:"${SERVICE_USER}" "${CONFIG_DIR}"
chmod 750 "${CONFIG_DIR}"
-say "psutil + httpx kuruldu (sistem Python'ı değişmedi)"
+say "psutil + httpx installed (the system Python was left untouched)"
# ===========================================================================
# 5/7 Yapılandırma — cihaz anahtarı burada sorulur.
# ===========================================================================
-step "5/7 Yapılandırma"
+step "5/7 Configuration"
WRITE_CONFIG=1
if [[ -f "${CONFIG_FILE}" ]]; then
- say "mevcut yapılandırma bulundu: ${CONFIG_FILE}"
- if ask_yes_no "Korunsun mu? (hayır derseniz cihaz anahtarı yeniden sorulur)"; then
+ say "existing configuration found: ${CONFIG_FILE}"
+ if ask_yes_no "Keep it? (answering no asks for the device key again)"; then
WRITE_CONFIG=0
- say "mevcut yapılandırma korundu"
+ say "existing configuration kept"
fi
fi
if (( WRITE_CONFIG )); then
- COLLECTOR_URL="$(prompt_default "Collector adresi" "${DEFAULT_COLLECTOR_URL}")"
- DEVICE_KEY="$(prompt_secret "Cihaz anahtarı (dashboard'da bir kez gösterilir)")"
+ COLLECTOR_URL="$(prompt_default "Collector address" "${DEFAULT_COLLECTOR_URL}")"
+ DEVICE_KEY="$(prompt_secret "Device key (shown once in the dashboard)")"
[[ "${DEVICE_KEY}" == "${KEY_PREFIX}"* ]] \
- || warn "anahtar '${KEY_PREFIX}' ile başlamıyor — doğru değeri yapıştırdığınızdan emin olun"
+ || warn "the key does not start with '${KEY_PREFIX}' — make sure you pasted the right value"
# Dosya İÇERİK yazılmadan önce kilitlenir: anahtar bir an bile başkalarının
# okuyabileceği bir dosyada durmasın.
@@ -318,22 +318,22 @@ if (( WRITE_CONFIG )); then
chmod 600 "${CONFIG_FILE}"
cat > "${CONFIG_FILE}" </dev/null 2>&1
systemctl restart "${SERVICE_NAME}"
-say "kuruldu ve başlatıldı: ${SERVICE_NAME}"
+say "installed and started: ${SERVICE_NAME}"
# İzleyici şimdi başlar ve kaldırma işaretini beklemeye koyulur. `enable --now`
# olmadan yalnızca bir sonraki açılışta devreye girerdi: bu makinede verilen
# ilk `delete` komutu, makine yeniden başlatılana kadar tamamlanmazdı.
systemctl enable --now "${UNINSTALL_PATH_UNIT}" >/dev/null 2>&1 \
- || warn "${UNINSTALL_PATH_UNIT} etkinleştirilemedi; delete komutu geldiğinde kaldırma elle yapılmalı"
-say "kaldırma izleyicisi etkin: ${UNINSTALL_PATH_UNIT} (delete komutunun root tarafı)"
+ || warn "could not enable ${UNINSTALL_PATH_UNIT}; a delete command would then need manual removal"
+say "removal watcher enabled: ${UNINSTALL_PATH_UNIT} (the root side of the delete command)"
# Servisin ilk saniyede düşüp düşmediğini görmek için kısa bir bekleme.
sleep 2
if systemctl is-active --quiet "${SERVICE_NAME}"; then
- say "servis çalışıyor"
+ say "service is running"
else
- warn "servis ayağa kalkmadı — ayrıntı: journalctl -u ${SERVICE_NAME} -n 30 --no-pager"
+ warn "the service did not come up — details: journalctl -u ${SERVICE_NAME} -n 30 --no-pager"
fi
# ===========================================================================
# 7/7 Bağlantı testi — GET /verify
# ===========================================================================
-step "7/7 Bağlantı testi"
+step "7/7 Connection test"
# Test, servisin çalıştığı kullanıcıyla yapılır: config'i okuyabildiği de
# böylece doğrulanmış olur.
@@ -392,15 +392,15 @@ VERIFY_OK=1
printf '\n'
if (( VERIFY_OK )); then
- printf '✓ TraceBox Agent kuruldu ve collector ile konuşuyor.\n\n'
+ printf '✓ TraceBox Agent is installed and talking to the collector.\n\n'
else
- printf '! TraceBox Agent kuruldu ama bağlantı testi başarısız.\n'
- printf ' Yapılandırmayı düzeltip tekrar deneyin:\n'
+ printf '! TraceBox Agent is installed but the connection test failed.\n'
+ printf ' Fix the configuration and try again:\n'
printf ' sudo nano %s\n' "${CONFIG_FILE}"
- printf ' sudo -u %s %s/bin/python -m agent --verify (%s içinden)\n\n' \
+ printf ' sudo -u %s %s/bin/python -m agent --verify (from inside %s)\n\n' \
"${SERVICE_USER}" "${VENV_DIR}" "${INSTALL_DIR}"
fi
-printf ' Durum : systemctl status %s\n' "${SERVICE_NAME}"
-printf ' Loglar : journalctl -u %s -f\n' "${SERVICE_NAME}"
-printf ' Kaldırma: sudo %s/uninstall.sh\n\n' "${INSTALL_DIR}"
+printf ' Status : systemctl status %s\n' "${SERVICE_NAME}"
+printf ' Logs : journalctl -u %s -f\n' "${SERVICE_NAME}"
+printf ' Uninstall: sudo %s/uninstall.sh\n\n' "${INSTALL_DIR}"
diff --git a/agent/logsources/linux_journald.py b/agent/logsources/linux_journald.py
index a23ceef..36c8db9 100644
--- a/agent/logsources/linux_journald.py
+++ b/agent/logsources/linux_journald.py
@@ -61,11 +61,36 @@
# ama systemd altında koşmayan süreçlerde boştur.
_SOURCE_FIELDS = ("_SYSTEMD_UNIT", "SYSLOG_IDENTIFIER", "_COMM")
+# Agent'ın KENDİ journald kimliği. Unit adı systemd altında, identifier ise
+# servis dışında (geliştirme, elle çalıştırma) dolar; ikisi birden bakılıyor.
+_SELF_UNIT = "tracebox-agent.service"
+_SELF_IDENTIFIER = "tracebox-agent"
+
+# Agent'ın KENDİ loglarından buluta gönderilen en düşük öncelik (4 = warning).
+#
+# Sorun bir geri besleme döngüsüydü: agent ekrana yazıyor → systemd journald'a
+# koyuyor → agent journald'ı okuyup kendi satırlarını buluta geri gönderiyor.
+# Her tur en az bir satır ürettiği için günde ~26.000 satır — tek bir cihaz
+# için, hiçbiri o cihaz hakkında değil. Kullanıcının 10 günlük penceresini
+# agent'ın kendi gevezeliği dolduruyordu.
+#
+# Tamamen susturmak yanlış olurdu: "collector'a ulaşılamadı" cümlesi
+# kullanıcının görmesi gereken bir şey ve onu başka hiçbir yerde göremez.
+# Ayrım SEVİYEDE yapılıyor — rutin tur bilgisi ("6 metrik gönderildi") info,
+# arıza warning ve üstü. Hacim kayboluyor, teşhis kalıyor.
+#
+# Not: agent'ın kendi error satırı hâlâ acil gönderim tetikleyebilir (§7).
+# Bu bilerek böyle: tetikleme `flush_cooldown_seconds` ile zaten sınırlı ve
+# gönderim aralığıyla (10 sn) aynı mertebede, yani ölçülebilir bir maliyet
+# eklemiyor. Buna karşılık gerçek bir arızada veriyi biraz daha erken dışarı
+# taşıyor.
+_SELF_MIN_PRIORITY = 4
+
# Cursor geçersizleştiğinde kaydın kendisine düşülen not. Atlanan aralık
# sessizce kaybolmaz; dashboard'daki zaman çizelgesinde görünür.
_GAP_MESSAGE = (
- "journald cursor'ı geçersiz (journal döndü veya silindi) — "
- "bu ana kadarki loglar okunamadı, okuma şimdiden devam ediyor"
+ "journald cursor is no longer valid (the journal rotated or was cleared) — "
+ "logs up to this point could not be read; reading resumes from now"
)
_GAP_SOURCE = "tracebox-agent"
@@ -148,11 +173,11 @@ def _run(self, *args: str) -> subprocess.CompletedProcess:
check=False,
)
except FileNotFoundError as error:
- raise JournalError("journalctl bulunamadı — sistemde journald yok") from error
+ raise JournalError("journalctl not found — this system has no journald") from error
except subprocess.TimeoutExpired as error:
- raise JournalError(f"journalctl {READ_TIMEOUT_SECONDS:.0f}s içinde yanıt vermedi") from error
+ raise JournalError(f"journalctl did not respond within {READ_TIMEOUT_SECONDS:.0f}s") from error
except OSError as error:
- raise JournalError(f"journalctl çalıştırılamadı ({error.__class__.__name__})") from error
+ raise JournalError(f"could not run journalctl ({error.__class__.__name__})") from error
def _parse(stdout: str) -> list[dict]:
@@ -177,7 +202,15 @@ def _parse(stdout: str) -> list[dict]:
def _to_record(entry: dict) -> LogRecord | None:
- """journald girdisini LogRecord'a indirger. Metinsiz girdi atlanır."""
+ """journald girdisini LogRecord'a indirger.
+
+ İki girdi atlanır ve None döner: metinsiz olanlar ve agent'ın kendi
+ rutin (info) satırları. Atlanan girdi de cursor'ı ilerletir — çağıran
+ yer imini kaydın kendisinden değil, girdiden okuyor.
+ """
+ if _is_self_chatter(entry):
+ return None
+
message = _message_text(entry.get("MESSAGE"))
if not message.strip():
return None
@@ -190,6 +223,28 @@ def _to_record(entry: dict) -> LogRecord | None:
)
+def _is_self_chatter(entry: dict) -> bool:
+ """Girdi, agent'ın kendi rutin (info) satırı mı?
+
+ Ölçüt İKİ parçalı: kaynak agent olacak VE önceliği warning'in altında
+ kalacak. Yalnızca kaynağa baksaydık agent'ın arıza mesajları da yok
+ olurdu; yalnızca seviyeye baksaydık makinedeki bütün info logları
+ gitmiş olurdu — oysa asıl toplamak istediğimiz şey onlar.
+ """
+ if entry.get("_SYSTEMD_UNIT") != _SELF_UNIT and (
+ entry.get("SYSLOG_IDENTIFIER") != _SELF_IDENTIFIER
+ ):
+ return False
+
+ priority = entry.get("PRIORITY")
+ try:
+ # journald PRIORITY'yi metin olarak verir ("6"). Okunamayan bir değeri
+ # "önemli" saymak güvenli taraf: şüphede kalan kayıt gönderilir.
+ return int(priority) > _SELF_MIN_PRIORITY
+ except (TypeError, ValueError):
+ return False
+
+
def _message_text(value: object) -> str:
"""MESSAGE alanını metne çevirir.
@@ -259,4 +314,4 @@ def _gap_record() -> LogRecord:
def _stderr_summary(result: subprocess.CompletedProcess) -> str:
"""journalctl'in hata satırı — logda görünecek kadar kısa."""
detail = (result.stderr or "").strip().splitlines()
- return detail[0][:200] if detail else f"çıkış kodu {result.returncode}"
+ return detail[0][:200] if detail else f"exit code {result.returncode}"
diff --git a/agent/tracebox-agent.service b/agent/tracebox-agent.service
index 9f54acb..e4e6e71 100644
--- a/agent/tracebox-agent.service
+++ b/agent/tracebox-agent.service
@@ -1,5 +1,5 @@
[Unit]
-Description=TraceBox Agent — metrik ve log toplayıp collector'a gönderir
+Description=TraceBox Agent — collects metrics and logs and ships them to the collector
Documentation=https://github.com/denisergocmen/tracebox
# Ağ hazır olmadan başlamak anlamsız değil ama gereksiz: agent zaten
# gönderemediği veriyi spool'da tutar. Yine de ilk gönderimin boşa gitmemesi
@@ -30,6 +30,12 @@ ExecStart=/opt/tracebox/venv/bin/python -m agent
# düşsün, `systemctl status` gecikmeli göstermesin.
Environment=PYTHONUNBUFFERED=1
+# Sabit journald kimliği. Agent kendi rutin satırlarını okurken buna bakıyor
+# (logsources/linux_journald.py → _SELF_IDENTIFIER); ayarlanmasaydı kimlik
+# yorumlayıcının adından ("python") türer ve makinedeki her Python süreci
+# agent sanılırdı.
+SyslogIdentifier=tracebox-agent
+
Restart=on-failure
RestartSec=5
diff --git a/agent/tracebox-uninstall.path b/agent/tracebox-uninstall.path
index d4843e9..1fd57d6 100644
--- a/agent/tracebox-uninstall.path
+++ b/agent/tracebox-uninstall.path
@@ -1,5 +1,5 @@
[Unit]
-Description=TraceBox — silinen cihazda kaldırmayı tetikler
+Description=TraceBox — triggers removal on a deleted device
Documentation=https://github.com/denisergocmen/tracebox
[Path]
diff --git a/agent/tracebox-uninstall.service b/agent/tracebox-uninstall.service
index 96d519e..2471428 100644
--- a/agent/tracebox-uninstall.service
+++ b/agent/tracebox-uninstall.service
@@ -1,5 +1,5 @@
[Unit]
-Description=TraceBox Agent'ı kaldırır (delete komutunun root tarafı)
+Description=Removes the TraceBox Agent (the root side of the delete command)
Documentation=https://github.com/denisergocmen/tracebox
# Kaldırma betiği yoksa çalışacak bir şey de yok; birim `failed` yerine sessizce
diff --git a/agent/uninstall.sh b/agent/uninstall.sh
index accf866..90e0c22 100755
--- a/agent/uninstall.sh
+++ b/agent/uninstall.sh
@@ -33,32 +33,32 @@ fail() { printf '\n✗ %s\n' "$*" >&2; exit 1; }
# --- Ön koşullar -----------------------------------------------------------
-[[ ${EUID} -eq 0 ]] || fail "Bu betik root yetkisi ister: sudo ./uninstall.sh"
+[[ ${EUID} -eq 0 ]] || fail "This script needs root: sudo ./uninstall.sh"
# --yes verilmediyse onay iste. Silinen şeyler geri getirilemez (anahtar dahil).
if [[ "${1:-}" != "--yes" ]]; then
- printf 'TraceBox Agent kaldırılacak:\n'
- printf ' - %s durdurulup devre dışı bırakılacak\n' "${SERVICE_NAME}"
- printf ' - %s izleyicisi kaldırılacak\n' "${UNINSTALL_PATH_UNIT}"
- printf ' - %s, %s, %s silinecek\n' "${INSTALL_DIR}" "${CONFIG_DIR}" "${STATE_DIR}"
- printf ' - %s kullanıcısı silinecek\n' "${SERVICE_USER}"
- printf '\nCihaz anahtarı da silinir; cihazı tekrar eklemek için yeni anahtar gerekir.\n'
- read -r -p 'Devam edilsin mi? [e/H] ' answer
- [[ "${answer}" == "e" || "${answer}" == "E" ]] || fail "İptal edildi."
+ printf 'TraceBox Agent will be removed:\n'
+ printf ' - %s will be stopped and disabled\n' "${SERVICE_NAME}"
+ printf ' - the %s watcher will be removed\n' "${UNINSTALL_PATH_UNIT}"
+ printf ' - %s, %s and %s will be deleted\n' "${INSTALL_DIR}" "${CONFIG_DIR}" "${STATE_DIR}"
+ printf ' - the %s user will be deleted\n' "${SERVICE_USER}"
+ printf '\nThe device key is deleted too; adding this host again needs a new key.\n'
+ read -r -p 'Continue? [y/N] ' answer
+ [[ "${answer}" == "y" || "${answer}" == "Y" ]] || fail "Cancelled."
fi
# --- 1) Servisi durdur -----------------------------------------------------
# Dosyalardan ÖNCE durdurulur: yoksa systemd, kodu silinmiş bir servisi
# Restart=on-failure ile yeniden başlatmayı dener.
-step "Servis durduruluyor"
+step "Stopping the service"
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now "${SERVICE_NAME}" >/dev/null 2>&1 || true
- say "durduruldu ve devre dışı bırakıldı"
+ say "stopped and disabled"
# İzleyici de kapatılır; işaret dosyası birazdan silinecek dizinde duruyor.
systemctl disable --now "${UNINSTALL_PATH_UNIT}" >/dev/null 2>&1 || true
- say "izleyici kapatıldı: ${UNINSTALL_PATH_UNIT}"
+ say "watcher disabled: ${UNINSTALL_PATH_UNIT}"
# DİKKAT: ${UNINSTALL_SERVICE} durdurulmaz. Bu betiği şu anda o birim
# çalıştırıyor olabilir; `stop` demek kendi süreç ağacını öldürmek, yani
@@ -66,13 +66,13 @@ if command -v systemctl >/dev/null 2>&1; then
# yok (yalnızca path unit tetikler), dolayısıyla disable edilecek bir bağ
# zaten yok — unit dosyasını silmek yeterli.
else
- say "systemctl yok — atlandı"
+ say "no systemctl — skipped"
fi
for unit_file in "${UNIT_PATH}" "${UNINSTALL_PATH_UNIT_PATH}" "${UNINSTALL_SERVICE_PATH}"; do
if [[ -f "${unit_file}" ]]; then
rm -f "${unit_file}"
- say "unit dosyası silindi: ${unit_file}"
+ say "unit file removed: ${unit_file}"
fi
done
@@ -86,13 +86,13 @@ fi
# --- 2) Dosyaları sil ------------------------------------------------------
-step "Dosyalar siliniyor"
+step "Deleting files"
for path in "${INSTALL_DIR}" "${CONFIG_DIR}" "${STATE_DIR}"; do
if [[ -e "${path}" ]]; then
rm -rf "${path}"
- say "silindi: ${path}"
+ say "deleted: ${path}"
else
- say "zaten yok: ${path}"
+ say "already gone: ${path}"
fi
done
@@ -100,15 +100,15 @@ done
# En sona bırakılır: kullanıcı hâlâ varken dosya sahipliği tutarlı kalır ve
# servis durmadan userdel zaten başarısız olurdu.
-step "Kullanıcı siliniyor"
+step "Deleting the user"
if id "${SERVICE_USER}" >/dev/null 2>&1; then
if userdel "${SERVICE_USER}" 2>/dev/null; then
- say "silindi: ${SERVICE_USER}"
+ say "deleted: ${SERVICE_USER}"
else
- say "UYARI: ${SERVICE_USER} silinemedi (kullanıcıya ait süreç kalmış olabilir)"
+ say "WARNING: could not delete ${SERVICE_USER} (a process of theirs may still be running)"
fi
else
- say "zaten yok: ${SERVICE_USER}"
+ say "already gone: ${SERVICE_USER}"
fi
-printf '\n✓ TraceBox Agent kaldırıldı.\n'
+printf '\n✓ TraceBox Agent removed.\n'
diff --git a/collector/auth.py b/collector/auth.py
index 59f4e74..8eac7ef 100644
--- a/collector/auth.py
+++ b/collector/auth.py
@@ -89,13 +89,13 @@ class UserIdentity:
# olmadığı, biçiminin doğru olup olmadığı dışarıya sızmaz.
_UNAUTHORIZED_DEVICE = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Geçersiz cihaz anahtarı.",
+ detail="Invalid device key.",
headers={"WWW-Authenticate": "Bearer"},
)
_UNAUTHORIZED_USER = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Geçersiz oturum.",
+ detail="Invalid session.",
headers={"WWW-Authenticate": "Bearer"},
)
@@ -177,7 +177,7 @@ async def _refresh(self) -> None:
key_set = PyJWKSet.from_dict(document)
except (httpx.HTTPError, ValueError, PyJWTError) as error:
# Adres loglanır (sır değil), yanıt gövdesi loglanmaz.
- logger.error("JWKS çekilemedi (%s): %r", url, error)
+ logger.error("could not fetch JWKS (%s): %r", url, error)
raise _JwksUnavailable(str(error)) from error
# `kid` taşımayan anahtar eşleştirmede kullanılamaz, atlanır.
@@ -221,7 +221,7 @@ async def require_device(
except SupabaseError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Doğrulama şu an yapılamıyor.",
+ detail="Authentication is temporarily unavailable.",
) from error
# Satır sorgusu zaten hash eşitliğiyle yapıldı; karşılaştırma sabit süreli
@@ -264,7 +264,7 @@ async def require_user(
except _JwksUnavailable as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Doğrulama şu an yapılamıyor.",
+ detail="Authentication is temporarily unavailable.",
) from error
if key is None:
diff --git a/collector/cors.py b/collector/cors.py
new file mode 100644
index 0000000..23e7cdf
--- /dev/null
+++ b/collector/cors.py
@@ -0,0 +1,95 @@
+"""
+CORS — tarayıcının collector'a doğrudan istek atabilmesi için.
+
+Neden gerekli: collector'ın uçlarından yalnızca **biri** tarayıcıdan çağrılıyor,
+`POST /devices` (§9.1 — dashboard cihazı buradan açar, düz anahtar bir kez
+burada döner). Diğer üç uç agent'a ait; agent bir tarayıcı değil, CORS onu hiç
+ilgilendirmiyor. Bu ayar olmadan dashboard'un "Add Host" düğmesi tarayıcı
+tarafından, isteği sunucuya hiç göndermeden bloklanır (§9.13).
+
+**CORS bir güvenlik duvarı DEĞİLDİR** — burada yazan hiçbir şey `curl`'ü ya da
+başka bir sunucuyu durdurmaz. Ucu koruyan şey user JWT doğrulaması (`auth.py`).
+Buradaki liste yalnızca *tarayıcıya* "şu sayfanın benimle konuşmasına izin
+veriyorum" der. Yine de yıldız (`*`) yerine açık liste kullanılıyor: yanlışlıkla
+herkese açılmış bir uç, ileride çerez tabanlı bir kimliğe geçilirse sessizce
+gerçek bir açığa dönüşür.
+
+`allow_credentials` bilerek **False**: dashboard kimliğini çerezle değil,
+elle eklediği `Authorization: Bearer ` başlığıyla taşıyor. True olsaydı
+tarayıcı çerezleri de gönderirdi ve Starlette yıldız kullanımını yasaklardı —
+ihtiyaç olmayan bir yetkiyi açık bırakmanın anlamı yok.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+logger = logging.getLogger("tracebox.cors")
+
+ALLOWED_ORIGINS_ENV = "TRACEBOX_ALLOWED_ORIGINS"
+
+# Env değişkeni tanımlı değilken geçerli olan liste.
+#
+# localhost'un üretimde de listede kalması zararsız: `Origin` başlığını sayfa
+# değil TARAYICI yazar, yani saldırganın sayfası kendi adresini taşımak
+# zorundadır — "http://localhost:3000" diye imzalayamaz. Buna karşılık geliştirme
+# sırasında kimsenin bir değişken tanımlamak zorunda kalmaması, kurulum
+# adımlarından birini tamamen siliyor.
+DEFAULT_ORIGINS = ("http://localhost:3000",)
+
+# Yalnızca POST /devices tarayıcıdan çağrılıyor. GET/PUT/DELETE açmanın bir
+# karşılığı yok; liste ileride bir uç eklenirse büyür.
+ALLOWED_METHODS = ("POST",)
+
+# Dashboard'un gönderdiği iki başlık: kimlik ve gövde tipi. Başka bir başlık
+# eklenmesi gerekmiyor — `*` yazmak, ileride eklenecek her başlığı görünmez
+# şekilde onaylamak olurdu.
+ALLOWED_HEADERS = ("Authorization", "Content-Type")
+
+# Preflight (OPTIONS) yanıtının tarayıcıda saklanma süresi. 10 dakika, "Add
+# Host" penceresini üst üste açan bir kullanıcının her seferinde ikinci bir
+# gidiş-dönüş ödememesi için yeterli.
+PREFLIGHT_CACHE_SECONDS = 600
+
+
+def parse_origins(raw: str | None) -> list[str]:
+ """Virgülle ayrılmış listeyi ayrıştırır.
+
+ Sondaki eğik çizgi kırpılıyor, çünkü tarayıcının gönderdiği `Origin` başlığı
+ onu ASLA taşımaz ve Starlette karşılaştırmayı tam metin üzerinden yapar:
+ ayarda "https://app.example.com/" yazsaydı hiçbir istek eşleşmez, üstelik
+ hata da vermezdi — sessizce çalışmayan bir ayar olurdu.
+ """
+ if not raw:
+ return []
+ return [origin.strip().rstrip("/") for origin in raw.split(",") if origin.strip()]
+
+
+def install_cors(app: FastAPI) -> None:
+ """CORS middleware'ini uygulamaya takar."""
+ configured = parse_origins(os.environ.get(ALLOWED_ORIGINS_ENV))
+ origins = configured or list(DEFAULT_ORIGINS)
+
+ # Env değişkeni listeyi GENİŞLETMEZ, DEĞİŞTİRİR. Üretimde dashboard'un
+ # adresi tanımlandığında localhost kendiliğinden düşsün isteniyor.
+ if configured:
+ logger.info("CORS origins from %s: %s", ALLOWED_ORIGINS_ENV, ", ".join(origins))
+ else:
+ logger.warning(
+ "%s is not set; only the default origins are allowed: %s",
+ ALLOWED_ORIGINS_ENV,
+ ", ".join(origins),
+ )
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=origins,
+ allow_credentials=False,
+ allow_methods=list(ALLOWED_METHODS),
+ allow_headers=list(ALLOWED_HEADERS),
+ max_age=PREFLIGHT_CACHE_SECONDS,
+ )
diff --git a/collector/db_access.py b/collector/db_access.py
index 4850708..b330410 100644
--- a/collector/db_access.py
+++ b/collector/db_access.py
@@ -36,5 +36,5 @@ async def call_or_503(operation):
except SupabaseError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Kayıt şu an yazılamıyor.",
+ detail="The record could not be written right now.",
) from error
diff --git a/collector/endpoints_device.py b/collector/endpoints_device.py
index d6a4dd9..b3862e3 100644
--- a/collector/endpoints_device.py
+++ b/collector/endpoints_device.py
@@ -78,12 +78,12 @@ async def post_devices(payload: DeviceCreateIn, user: AuthenticatedUser) -> dict
if error.code == UNIQUE_VIOLATION:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
- detail="Bu hesapta aynı adı taşıyan bir cihaz zaten var.",
+ detail="A host with this name already exists in this account.",
) from error
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Cihaz şu an oluşturulamıyor.",
+ detail="The host could not be created right now.",
) from error
# `device_key` yalnızca bu yanıtta görünür. Kaybedilirse geri getirilemez;
diff --git a/collector/endpoints_ingest.py b/collector/endpoints_ingest.py
index 9d1c5c3..93ddc69 100644
--- a/collector/endpoints_ingest.py
+++ b/collector/endpoints_ingest.py
@@ -254,7 +254,7 @@ def _external_ip(header_value: str | None, enabled_addons: list[str]) -> str | N
return str(ip_address(header_value.strip()))
except ValueError:
# Değerin kendisi loglanmaz: doğrulanmamış, dışarıdan gelen bir metin.
- logger.warning("%s başlığı IP adresi olarak çözülemedi", CLIENT_IP_HEADER)
+ logger.warning("could not parse the %s header as an IP address", CLIENT_IP_HEADER)
return None
diff --git a/collector/main.py b/collector/main.py
index 847ecc4..909ec5d 100644
--- a/collector/main.py
+++ b/collector/main.py
@@ -19,6 +19,7 @@
from fastapi import FastAPI
import supabase_client
+from cors import install_cors
from endpoints_commands import router as commands_router
from endpoints_device import router as device_router
from endpoints_ingest import router as ingest_router
@@ -55,6 +56,12 @@ async def lifespan(app: FastAPI):
openapi_url=None,
)
+# CORS router'lardan ÖNCE takılıyor: middleware yığını dıştan içe çalışıyor,
+# yani preflight (OPTIONS) isteğinin bir yönlendirme aranmadan yanıtlanması
+# gerekiyor. FastAPI OPTIONS için ayrı bir yol tanımlamadığından, middleware
+# olmasaydı tarayıcının ön sorusu 405 ile dönerdi.
+install_cors(app)
+
app.include_router(device_router)
app.include_router(ingest_router)
app.include_router(commands_router)
diff --git a/collector/supabase_client.py b/collector/supabase_client.py
index eda7d7a..aff7867 100644
--- a/collector/supabase_client.py
+++ b/collector/supabase_client.py
@@ -150,11 +150,11 @@ async def update_device(self, device_id: str, fields: dict[str, Any]) -> None:
# Yalnızca sütun ADLARI loglanır — değerler loglanmaz; reddedilen
# alan `key_hash` gibi bir sır olabilir.
logger.error(
- "devices güncellemesi reddedildi — izinsiz sütun: %s",
+ "devices update rejected — column not allowed: %s",
", ".join(forbidden),
)
raise ValueError(
- f"devices tablosunda yazılamayacak sütun(lar): {', '.join(forbidden)}"
+ f"column(s) that may not be written on the devices table: {', '.join(forbidden)}"
)
await self._request(
@@ -186,7 +186,7 @@ async def insert_device(self, row: dict[str, Any]) -> dict[str, Any]:
if not rows:
# PostgREST temsil istendiğinde satırı döndürür; boş gövde
# beklenmedik bir durumdur ve sessizce geçilmemelidir.
- raise SupabaseError("POST /devices: oluşturulan satır okunamadı")
+ raise SupabaseError("POST /devices: could not read back the created row")
return rows[0]
@@ -280,13 +280,13 @@ async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Respons
try:
response = await self._client.request(method, path, **kwargs)
except httpx.HTTPError as error:
- logger.error("Supabase %s %s ulaşılamadı: %r", method, path, error)
+ logger.error("Supabase %s %s unreachable: %r", method, path, error)
raise SupabaseError(f"{method} {path}: {error}") from error
if response.is_error:
code = _error_code(response)
logger.error(
- "Supabase %s %s → %s (kod: %s)",
+ "Supabase %s %s → %s (code: %s)",
method,
path,
response.status_code,
@@ -355,7 +355,7 @@ def init_client() -> SupabaseClient:
if not value
]
if missing:
- raise RuntimeError(f"Eksik ortam değişkeni: {', '.join(missing)}")
+ raise RuntimeError(f"Missing environment variable: {', '.join(missing)}")
# Yalnızca adres ve anahtarın ön eki loglanır — anahtarın kendisi asla.
logger.info("Supabase hedefi: %s (anahtar: %s…)", url, service_key[:11])
@@ -379,7 +379,7 @@ async def close_client() -> None:
def get_client() -> SupabaseClient:
"""Kurulmuş istemciyi döndürür — endpoint'ler bunu kullanır."""
if _client is None:
- raise RuntimeError("Supabase istemcisi kurulmadı.")
+ raise RuntimeError("Supabase client was not initialised.")
return _client
@@ -391,6 +391,6 @@ def get_project_url() -> str:
token'daki `iss` alanının beklenen değerini hesaplamak.
"""
if _project_url is None:
- raise RuntimeError("Supabase istemcisi kurulmadı.")
+ raise RuntimeError("Supabase client was not initialised.")
return _project_url
diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore
new file mode 100644
index 0000000..321c1a9
--- /dev/null
+++ b/dashboard/.dockerignore
@@ -0,0 +1,37 @@
+# =============================================================================
+# Docker build bağlamı dışında kalacaklar.
+#
+# .gitignore'un kopyası DEĞİL — iki dosya iki farklı soruya cevap veriyor.
+# Docker, git'in ne düşündüğüne bakmaz: `fly deploy` bu klasörün tamamını
+# paketleyip uzak builder'a yollar. Buradaki her satır hem yükleme süresini
+# hem de imaja sızabilecek şeyleri kısıyor.
+# =============================================================================
+
+# --- SIRLAR ------------------------------------------------------------------
+# .env.local gerçek Supabase adresini ve anon key'i tutuyor. Bunlar sır değil
+# (bkz. .env.example) ama imaja dosya olarak girmelerinin bir sebebi de yok:
+# derleme değerleri build-arg ile geliyor, bir dosyanın sessizce onları
+# ezmesini istemiyoruz.
+.env
+.env.*
+!.env.example
+
+# --- yeniden üretilebilenler -------------------------------------------------
+# node_modules imajın içinde `npm ci` ile yeniden kuruluyor; yerelden kopyalamak
+# hem yüzlerce megabayt yükleme hem de yanlış platform için derlenmiş ikili
+# dosyalar demek (yerel makine ile builder aynı mimaride olmayabilir).
+node_modules
+.next
+out
+*.tsbuildinfo
+next-env.d.ts
+
+# --- imajda işi olmayanlar ---------------------------------------------------
+# example/ tasarım referans görselleri: onlarca megabayt PNG, çalışma zamanında
+# hiç okunmuyor. .gitignore'da da var ama Docker onu görmüyor.
+example
+README.md
+.git
+.gitignore
+.vscode
+.DS_Store
diff --git a/dashboard/.env.example b/dashboard/.env.example
new file mode 100644
index 0000000..20ee6f7
--- /dev/null
+++ b/dashboard/.env.example
@@ -0,0 +1,13 @@
+# Dashboard yalnızca TARAYICIDAN Supabase'e bağlanır (§9.1) — bu yüzden iki
+# değişken de NEXT_PUBLIC_ önekli, yani derlemede JS'e gömülür ve herkes görür.
+#
+# BU BİR SIR DEĞİLDİR. anon key'in tek yetkisi "RLS'e tabi bir istemciyim"
+# demektir; hangi satırı göreceğine kullanıcının JWT'si ve RLS politikaları
+# karar verir (account_id = auth.uid()). Sır olan SUPABASE_SERVICE_KEY'dir ve
+# o yalnızca collector'da durur, buraya ASLA konmaz.
+NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co
+NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...
+
+# Collector'ın adresi: yalnızca "Cihaz Ekle" (POST /devices) için kullanılır.
+# Okuma istekleri buraya GİTMEZ.
+NEXT_PUBLIC_COLLECTOR_URL=https://tracebox-collector.fly.dev
diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile
new file mode 100644
index 0000000..48e6052
--- /dev/null
+++ b/dashboard/Dockerfile
@@ -0,0 +1,97 @@
+# =============================================================================
+# TraceBox Dashboard — container image.
+#
+# Collector'dan AYRI bir Fly app (CLAUDE.md §9.1). Bu imajın tek işi JS/HTML
+# SUNMAK: veri çekilmiyor, hiçbir istek buradan Supabase'e gitmiyor. Sunucu
+# tarafında sır YOK — tarayıcı Supabase'e kendi JWT'siyle bağlanıyor ve RLS
+# koruyor.
+#
+# Üç aşama: bağımlılıklar → derleme → çalıştırma. Son imaja yalnızca üçüncü
+# aşama giriyor; npm önbelleği, kaynak kod ve devDependencies dışarıda kalıyor.
+# =============================================================================
+
+# --- 1) bağımlılıklar --------------------------------------------------------
+FROM node:22-alpine AS deps
+WORKDIR /app
+
+# Yalnızca manifest kopyalanıyor: kaynak kod değiştiğinde bu katman
+# önbellekten geliyor ve `npm ci` yeniden çalışmıyor.
+COPY package.json package-lock.json ./
+
+# `npm ci` (install değil): lock dosyasına birebir uyar ve uymuyorsa hata
+# verir. Derlemenin, yerelde denenmemiş bir sürümle sessizce ilerlemesi
+# imkânsız olmalı.
+RUN npm ci
+
+# --- 2) derleme --------------------------------------------------------------
+FROM node:22-alpine AS builder
+WORKDIR /app
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+# NEXT_PUBLIC_* değişkenleri DERLEME ANINDA koda gömülür; çalışma zamanı
+# ortam değişkeni olarak verilemezler. Bu yüzden Fly secret DEĞİL, build-arg
+# olarak geliyorlar — `fly.toml` → [build.args].
+#
+# Üçü de tarayıcıya inen, herkesin görebileceği değerler (bkz. .env.example):
+# anon key'in tek yetkisi "RLS'e tabi bir istemciyim" demek. SIR olan
+# SUPABASE_SERVICE_KEY buraya ASLA girmez; o yalnızca collector'ın Fly
+# secret'ıdır.
+ARG NEXT_PUBLIC_SUPABASE_URL
+ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
+ARG NEXT_PUBLIC_COLLECTOR_URL
+ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY \
+ NEXT_PUBLIC_COLLECTOR_URL=$NEXT_PUBLIC_COLLECTOR_URL
+
+# Eksik bir build-arg SESSİZCE geçmemeli. `next build` bu değerler olmadan da
+# başarıyla biter — çünkü Supabase istemcisi ilk çağrıda, yani TARAYICIDA
+# kuruluyor. Sonuç, deploy'un yeşil göründüğü ama açan herkesin boş bir ekran
+# ve konsolda "Missing environment variable" gördüğü bir imaj olurdu.
+# Derlemeyi burada, hâlâ ucuzken durduruyoruz.
+RUN for v in NEXT_PUBLIC_SUPABASE_URL NEXT_PUBLIC_SUPABASE_ANON_KEY NEXT_PUBLIC_COLLECTOR_URL; do \
+ eval "value=\$$v"; \
+ if [ -z "$value" ]; then \
+ echo "ERROR: $v is empty. Every NEXT_PUBLIC_* value must reach the"; \
+ echo " build as --build-arg. See README, Deploying the dashboard."; \
+ exit 1; \
+ fi; \
+ done
+
+ENV NEXT_TELEMETRY_DISABLED=1
+RUN npm run build
+
+# --- 3) çalıştırma -----------------------------------------------------------
+FROM node:22-alpine AS runner
+WORKDIR /app
+
+# HOSTNAME: Next'in standalone sunucusu varsayılanda localhost'a bağlanır; o
+# hâlde container dışından, yani Fly proxy'sinden erişilemezdi.
+ENV NODE_ENV=production \
+ NEXT_TELEMETRY_DISABLED=1 \
+ PORT=3000 \
+ HOSTNAME=0.0.0.0
+
+# `output: "standalone"` (next.config.ts) yalnızca gerçekten import edilen
+# modülleri toplayıp yanına küçük bir sunucu koyuyor — node_modules'ün tamamı
+# imaja girmiyor. Küçük imaj = hızlı soğuk açılış; aşağıdaki uyku kararı
+# (fly.toml) bunun üstüne kurulu.
+COPY --from=builder --chown=node:node /app/.next/standalone ./
+# static ve public standalone çıktısının DIŞINDA kalır; ayrıca kopyalanmazsa
+# sayfa açılır ama CSS'siz ve logosuz gelir.
+COPY --from=builder --chown=node:node /app/.next/static ./.next/static
+COPY --from=builder --chown=node:node /app/public ./public
+
+# next/image, istenen boyutu ÇALIŞMA ZAMANINDA üretip .next/cache/images
+# altına yazıyor. Dizin yoksa ve yazılamıyorsa logo sessizce kırık gelir —
+# yetkisiz kullanıcıya geçmeden önce açılıp devrediliyor.
+RUN mkdir -p /app/.next/cache && chown -R node:node /app/.next
+
+# Yetkisiz kullanıcı. node imajında hazır bir `node` kullanıcısı var; ayrıca
+# açmaya gerek yok.
+USER node
+
+EXPOSE 3000
+
+CMD ["node", "server.js"]
diff --git a/dashboard/README.md b/dashboard/README.md
index cc9e36f..c07c0c7 100644
--- a/dashboard/README.md
+++ b/dashboard/README.md
@@ -1,29 +1,95 @@
-# Dashboard — M9'da gelecek
-
-Next.js + Tailwind. Kullanıcının **okuma** penceresi.
-
-Bu klasör M9'a kadar bilerek boş. Sebep: dikey dilim yaklaşımı. Önce veri
-gerçekten Supabase'e düşsün (M3), sonra onu gösteren arayüz yazılsın. Tersini
-yapmak, henüz var olmayan verinin sahte kopyasına karşı UI geliştirmek olurdu.
-
-## Kurulacak mimari
-
-- **Okuma:** Supabase client ile **doğrudan** Postgres'ten. Collector'a hiçbir
- okuma isteği gitmez — RLS (`account_id = auth.uid()`) zaten satır bazında
- koruyor, araya bir API katmanı koymak fayda getirmeden gecikme eklerdi.
-- **Auth:** Supabase Auth (e-posta/şifre).
-- **Yazma:**
- - "Cihaz Ekle" → collector `POST /devices` (user JWT) → anahtar **bir kez**
- gösterilir. Bu tek yazma işlemi collector'dan geçer, çünkü anahtar üretimi
- ve hash'leme tarayıcıda yapılamaz.
- - pause / resume / delete → `commands` tablosuna doğrudan INSERT
- (RLS `ins_commands` politikası cihaz sahipliğini de doğrular).
-- **Barındırma:** Vercel.
-
-## Gösterilecekler
-
-- Cihaz kartları (CPU / RAM / disk anlık durum)
-- 10 günlük zaman çizelgesi (metrik + log birlikte)
-- `last_seen` üzerinden offline rozeti
-- pause / resume / delete butonları
-- Log seviye filtresi (info / warning / error / critical)
+# Dashboard — M9
+
+Next.js 16 (App Router) + Tailwind v4 + TypeScript. Kullanıcının **okuma** penceresi.
+Ayrıntılı spec: `CLAUDE.md` §9.
+
+## Mimari — tek cümle
+
+**Veriyi tarayıcı çeker.** Next.js SSR ile veri çekmez; sayfa yüklendikten sonra tarayıcı
+doğrudan Supabase'e bağlanır. Fly'ın işi yalnızca JS/HTML sunmak (§9.1). Bunun üç sonucu var:
+
+- Fly instance'ı küçük ve sabit yükte kalır — kullanıcı sayısı arttıkça büyümesi gerekmez.
+- Realtime (§9.9) mümkün olur; sunucu çekseydi canlı akış zaten çalışmazdı.
+- **Hiçbir route sunucuda veri çekmez.** `/devices/[id]` derleme çıktısında `ƒ` görünür
+ (dinamik segment, build anında id'ler bilinmiyor) ama sunucunun ürettiği şey boş bir
+ kabuktur; içindeki her satır tarayıcıda, kullanıcının kendi JWT'siyle iner.
+
+**Okuma:** Supabase client + user JWT; RLS (`account_id = auth.uid()`) satır bazında korur.
+**Collector'a hiçbir okuma isteği gitmez.**
+
+**Yazma:** yalnızca "Cihaz Ekle" collector'dan geçer (`POST /devices` — anahtar üretimi ve
+hash'leme tarayıcıda yapılamaz). pause/resume/delete `commands` tablosuna doğrudan INSERT,
+cihaz adı `devices` UPDATE, zorla kaldırma `devices` DELETE — hepsi RLS ve kolon grant'ları
+altında.
+
+## Bugünkü durum — iskelet
+
+| Yol | Durum |
+|---|---|
+| `/login` | **Geçici**, süssüz giriş formu. Kayıt ekranı YOK (§9.2) |
+| `/devices` | ✅ Cihaz kartları — künye, dört durum rozeti, üç ölçü çubuğu (§9.3) |
+| `/devices/[id]` | ✅ 70/30 yerleşim + künye paneli + log listesi (§9.4). Grafik ve aksiyonlar sonraki dilimlerde |
+| `/` | Ekran değil, yol ayrımı: oturum varsa `/devices`, yoksa `/login` |
+
+`/login` bilerek süssüzdür. §9.12'deki kara kutu animasyonlu vitrin **en son** yapılır;
+o zaman bu dosya baştan yazılır. Bugünkü işi, altındaki tesisatın (Supabase Auth → oturum →
+RLS'li okuma) gerçekten çalıştığını kanıtlamak.
+
+## Çalıştırma
+
+```bash
+cd dashboard
+npm install
+cp .env.example .env.local # değerleri Supabase panelinden doldur
+npm run dev # http://localhost:3000
+```
+
+`.env.local` git'e girmez (`.env.*` engelli). İki değişken de `NEXT_PUBLIC_` öneklidir,
+yani derlemede JS'e gömülür ve herkes görür — **bu bir sır değildir**. anon key'in tek
+yetkisi "RLS'e tabi bir istemciyim" demektir. Sır olan `SUPABASE_SERVICE_KEY`'dir ve
+yalnızca collector'da durur.
+
+**İlk hesap Supabase panelinden elle açılır** (Authentication → Users → Add user).
+`accounts` satırı `db/triggers.sql`'deki `on_auth_user_created` trigger'ı ile otomatik oluşur.
+
+## Klasör düzeni
+
+```
+dashboard/
+├── app/
+│ ├── layout.tsx # kök gövde + metadata
+│ ├── globals.css # görsel dil tokenleri (§9.11) — renkler BURADA, bileşende değil
+│ ├── page.tsx # yol ayrımı
+│ ├── login/page.tsx # geçici giriş formu
+│ └── devices/
+│ ├── page.tsx # ekran 2 — ızgara, 10 sn yenileme
+│ ├── DeviceCard.tsx # kart: künye + rozet + üç çubuk
+│ └── [id]/
+│ ├── page.tsx # ekran 3 — 70/30 yerleşim, ortak aralık state'i
+│ ├── Timeline.tsx # aralık düğmeleri (grafik yer tutucu)
+│ ├── LogList.tsx # blok blok artımlı log listesi
+│ └── DetailPanel.tsx # sağ panel: künye + (devre dışı) aksiyonlar
+├── lib/
+│ ├── supabase.ts # tek istemci, tembel kurulum
+│ ├── useSession.ts # loading / signedIn / signedOut — üçü AYRI hâl
+│ ├── devices.ts # cihaz sorguları + dört durumun türetilmesi
+│ ├── logs.ts # UTC gün blokları + seviye eşiği + sayfalama
+│ └── time.ts # "12 saniye önce", log saati, MB→GB
+├── next.config.ts # output: standalone (Fly Docker imajı için)
+└── postcss.config.mjs # Tailwind v4 — tailwind.config.js YOK, tema CSS içinde
+```
+
+## Henüz yapılmayanlar
+
+- **Zaman çizelgesi (§9.6–§9.8)** — seyreltme fonksiyonu bir migration gerektiriyor (§9.7).
+ Grafik gelince zoom ve "seçim log listesini de daraltır" bağı da gelir.
+- **Aksiyonlar (§9.10)** — pause/resume/delete, yıkıcı işlem onay penceresiyle birlikte.
+- **Canlı log akışı (§9.9)** — son 24 saat görünümünde Realtime; saniyede bir güncelleme
+ tavanı ve ~500 satırlık liste sınırı zorunlu.
+- **Blok tahliyesi (§9.5)** — "ekrandan 4 blok uzaklaşan bloklar bellekten atılır" kuralı
+ henüz uygulanmadı; bugünkü sınır, sayfa sayfa (200 satır) çekmenin kendisi.
+- **Deploy:** collector'dan **ayrı** bir Fly app olacak (§9.1). Dockerfile + fly.toml
+ henüz yazılmadı.
+- **CORS:** collector'da CORS middleware yok — "Cihaz Ekle" tarayıcıdan bugün bloklanır
+ (§9.13). Dashboard'ın origin'i belli olunca eklenecek.
+- **Grafik seyreltmesi:** cihaz detayı için bir migration çıkacak (§9.7).
diff --git a/dashboard/app/(app)/alerts/page.tsx b/dashboard/app/(app)/alerts/page.tsx
new file mode 100644
index 0000000..f16aad5
--- /dev/null
+++ b/dashboard/app/(app)/alerts/page.tsx
@@ -0,0 +1,223 @@
+/**
+ * Alerts — kenar çubuğundaki "Alerts" bölümünün sayfası.
+ *
+ * Overview'daki kart en ciddi ÜÇ satırı gösteriyor (referanstaki sayı); burası
+ * tamamı. Fark yalnızca uzunluk değil: kart bir bakış, bu sayfa bir çalışma
+ * yeri — satırlar ciddiyete göre gruplu ve her grup kaç tane olduğunu yazıyor.
+ *
+ * TraceBox'ta uyarı diye AYRI BİR TABLO YOK ve bu sayfa bir tane uydurmuyor.
+ * Her satır zaten bellekte olan cihaz listesinden türüyor (lib/alerts.ts):
+ * sessiz cihaz, agent'ın acil gönderim eşiğini aşan bir ölçü, eşiğe 15 puan
+ * yaklaşmış bir ölçü, duraklatılmış gönderim. Yani sayfa fazladan tek bir
+ * sorgu bile açmıyor.
+ *
+ * Bu, sayfanın en alttaki açıklama kutusunda kullanıcıya da SÖYLENİYOR.
+ * Söylenmezse "Critical" rozeti, arkasında bir alarm motoru varmış izlenimi
+ * verir; oysa satır, cihaz listesi tazelendiği anda kendiliğinden kaybolabilir.
+ * Seyreltilmiş grafiğin "her nokta = X" satırıyla aynı dürüstlük kuralı
+ * (§9.6 madde 5).
+ */
+"use client";
+
+import { useMemo } from "react";
+import Link from "next/link";
+import { useApp } from "@/lib/appState";
+import {
+ NEAR_THRESHOLD_MARGIN,
+ SEVERITY_LABEL,
+ buildAlerts,
+ type Alert,
+ type AlertSeverity,
+} from "@/lib/alerts";
+import type { Device } from "@/lib/devices";
+import { FLUSH_THRESHOLD } from "@/lib/metrics";
+import { OFFLINE_AFTER_SECONDS } from "@/lib/devices";
+import { clockTime, relativeTime } from "@/lib/time";
+import { PageHeader, Tally } from "@/components/PageHeader";
+import { IconAlert, IconChevron } from "@/components/icons";
+
+const TONE: Record<
+ AlertSeverity,
+ { icon: string; badge: string; rail: string }
+> = {
+ critical: {
+ icon: "text-danger",
+ badge: "bg-danger-soft text-danger",
+ rail: "bg-danger",
+ },
+ warning: {
+ icon: "text-warn",
+ badge: "bg-warn-soft text-warn",
+ rail: "bg-warn",
+ },
+ info: { icon: "text-info", badge: "bg-info-soft text-info", rail: "bg-info" },
+};
+
+/** Ciddiyet sırası — lib/alerts.ts'in sıralamasıyla aynı. */
+const ORDER: AlertSeverity[] = ["critical", "warning", "info"];
+
+function Row({ alert, now }: { alert: Alert; now: number }) {
+ const tone = TONE[alert.severity];
+ return (
+
+
+ {/* Soldaki renk şeridi: göz listeyi tararken ciddiyeti rozeti okumadan
+ yakalıyor. Rozet yine duruyor — renk tek başına erişilebilir değil. */}
+
+
+
+
+
+ {alert.title}
+
+
+ {alert.deviceName}
+
+
+
+
+ {SEVERITY_LABEL[alert.severity]}
+
+
+
+ {/* İki zaman biçimi bilerek yan yana: saat "ne zaman"ı, göreli süre
+ "ne kadar önce"yi cevaplıyor. Uzun sessizliklerde ikincisi tek
+ başına yeterli olmuyor, kısa olanlarda birincisi. */}
+ {alert.at == null
+ ? "—"
+ : `${clockTime(alert.at)} · ${relativeTime(new Date(alert.at).toISOString(), now)}`}
+
+
+
+
+
+
+ {/* --- ne sayılır, ne sayılmaz -------------------------------------- */}
+
+
How these are derived
+
+ TraceBox has no alert table and no alerting engine. Every row above is
+ computed in your browser from the host list you are already looking
+ at, so it appears and disappears with the data itself — there is
+ nothing to acknowledge or silence.
+
+
+
+ Host silent — no
+ contact for {OFFLINE_AFTER_SECONDS} seconds, six missed command
+ polls in a row.
+
+
+ Usage above threshold —
+ the latest sample crossed the level at which the agent flushes its
+ spool immediately: CPU {FLUSH_THRESHOLD.cpu}%, memory{" "}
+ {FLUSH_THRESHOLD.ram}%, disk {FLUSH_THRESHOLD.disk}%.
+
+
+ High usage — within{" "}
+ {NEAR_THRESHOLD_MARGIN} points of that same level.
+
+
+ Shipping paused /{" "}
+ Delete pending — a
+ command you queued is in effect or still waiting for the agent.
+
+
+
+
+ );
+}
diff --git a/dashboard/app/(app)/devices/AddHostDialog.tsx b/dashboard/app/(app)/devices/AddHostDialog.tsx
new file mode 100644
index 0000000..aa2a4b0
--- /dev/null
+++ b/dashboard/app/(app)/devices/AddHostDialog.tsx
@@ -0,0 +1,297 @@
+/**
+ * "Add Host" penceresi — cihaz kaydı + anahtarın BİR KEZ gösterilmesi.
+ *
+ * İki aşama, tek pencere:
+ * 1. isim → POST /devices (collector, user JWT)
+ * 2. anahtar → kopyala + kurulum satırı + "I saved this key"
+ *
+ * §9.10'un burada geçerli olan maddesi: *"Anahtar penceresi, 'Anahtarı
+ * kaydettim' onaylanmadan kapanmaz — kapandığında düz anahtar kalıcı olarak
+ * kaybolur"*. Bu yüzden ikinci aşamada Esc de, zemine tıklamak da pencereyi
+ * KAPATMIYOR. Birinci aşamada ikisi de çalışıyor: orada kaybedilecek bir şey
+ * yok, kapatmayı zorlaştırmak sadece can sıkardı.
+ *
+ * `ConfirmDialog` kullanılmadı. O bileşen tek bir soruyu soruyor ve metni
+ * kilitli; buradaki pencere iki aşamalı, bir form taşıyor ve gösterdiği şey
+ * bir uyarı değil bir SIR. Ortak bir bileşene zorlamak ikisini de bozardı.
+ */
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { createDevice } from "@/lib/collector";
+import { errorMessage } from "@/lib/errors";
+import { IconCheck, IconClose, IconCopy, IconKey } from "@/components/icons";
+
+/** Collector'daki `MAX_DEVICE_NAME_LENGTH` ile aynı sayı. */
+const MAX_NAME_LENGTH = 64;
+
+/** Kopyalandı geri bildiriminin ekranda kalma süresi. */
+const COPIED_FEEDBACK_MS = 2000;
+
+/**
+ * §8 — kullanıcı anahtarı alır, repoya yönlendirilir, install.sh'i çalıştırır.
+ *
+ * Betik indiriliyor ama ÇALIŞTIRILMIYOR: `curl | sudo bash` tek satırda
+ * kolaydır, ama kullanıcıya root yetkisiyle koşacak kodu okuma fırsatı
+ * bırakmaz. §8 kurulumu "indir, gözden geçir, çalıştır" diye tarif ediyor;
+ * satır da öyle.
+ */
+const INSTALL_COMMAND =
+ "curl -fsSL https://raw.githubusercontent.com/Denisergocmen924/TraceBox/master/agent/install.sh -o install.sh";
+
+/**
+ * Panoya kopyalama.
+ *
+ * `navigator.clipboard` yalnızca güvenli bağlamda (https ya da localhost) var;
+ * kullanıcı collector'ı düz http üzerinden açtığında tanımsız olur. Başarısız
+ * olduğunda sessizce geçmek yerine `false` dönüyor — çağıran taraf "kopyalandı"
+ * yazmak yerine kullanıcıyı elle seçmeye bırakabiliyor.
+ */
+async function copyToClipboard(text: string): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function CopyRow({ value, label }: { value: string; label: string }) {
+ const [copied, setCopied] = useState(false);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ if (!copied) return;
+ const timer = setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
+ return () => clearTimeout(timer);
+ }, [copied]);
+
+ return (
+
+
+ {label}
+
+
+
+ {/*
+ `select-all` bilerek: kopyalama düğmesi çalışmadığında (güvenli olmayan
+ bağlam) kullanıcı tek tıklamayla tümünü seçebilsin. Anahtar `break-all`
+ ile sarılıyor — kırpılsaydı elle seçen kullanıcı yarısını alırdı.
+ */}
+
+ {value}
+
+
+ {failed && (
+
+ Could not access the clipboard — select the text and copy it manually.
+
+ We only store a hash of this key. Once you close this window it is
+ gone for good — you would have to delete the host and start over.
+
+
+
+
+
+
Next step
+
+ Download the installer on the machine, read it, then run it with
+ sudo. It asks for the key — it is never passed on the command
+ line, so it stays out of your shell history.
+
+
+ {INSTALL_COMMAND}
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/dashboard/app/(app)/devices/DeviceCard.tsx b/dashboard/app/(app)/devices/DeviceCard.tsx
new file mode 100644
index 0000000..404bae3
--- /dev/null
+++ b/dashboard/app/(app)/devices/DeviceCard.tsx
@@ -0,0 +1,125 @@
+/**
+ * Cihaz kartı (CLAUDE.md §9.3).
+ *
+ * Kartın cevapladığı tek soru: "bu makine iyi mi?"
+ * Bu yüzden üstünde AKSİYON BUTONU YOK — tıklayınca detaya gider, pause/sil
+ * orada durur. Silme geri alınamaz bir iş; listede yanlışlıkla tıklanacak bir
+ * yerde durmamalı.
+ *
+ * Üç ölçünün tanımı (adı, rengi, yüzdesi, okunur hâli) lib/metrics.ts'teki
+ * SERIES'ten geliyor — detaydaki grafiklerle AYNI kaynak. Kartta RAM moru,
+ * grafikte mavisi olsaydı kullanıcı iki ekranı zihninde eşleştiremezdi.
+ */
+"use client";
+
+import Link from "next/link";
+import { deviceStatus, type Device } from "@/lib/devices";
+import { SERIES, type SeriesDef } from "@/lib/metrics";
+import { relativeTime } from "@/lib/time";
+import { percentTone } from "@/lib/alerts";
+import { StatusPill } from "@/components/StatusPill";
+import {
+ IconAlert,
+ IconChevron,
+ IconClock,
+ IconServer,
+} from "@/components/icons";
+
+/*
+ * Çubuğun rengi normalde ÖLÇÜNÜN rengi (CPU mor, RAM yeşil, Disk turuncu —
+ * referans 2'nin dört kartı). Eşiğe yaklaşıldığında DURUM rengi devralıyor,
+ * çünkü o noktada kullanıcının bilmesi gereken şey artık hangi ölçüye baktığı
+ * değil, sorunun kendisi. Hesap lib/alerts.ts'te: aynı eşik Overview'daki
+ * Host Status tablosunda ve Top Hosts çubuklarında da okunuyor.
+ */
+
+function Metric({
+ series,
+ percent,
+ value,
+}: {
+ series: SeriesDef;
+ percent: number | null;
+ value: string;
+}) {
+ const clamped = percent == null ? null : Math.min(100, Math.max(0, percent));
+ const { bar, text, alarm } = percentTone(series, clamped);
+
+ return (
+
+
+ {series.label}
+
+ {/*
+ Uyarı üçgeni, çubuk rengine ek bir sinyal. Disk rengi (turuncu) ile
+ uyarı kehribarı yakın akraba; sinyal yalnızca çubuğa bırakılsaydı
+ "disk eşiğe yaklaştı" hâli, normal disk çubuğundan ayırt edilemezdi.
+ */}
+ {alarm && }
+ {value}
+
+
+
+ Seen {relativeTime(device.last_seen, now)}
+
+
+
+ );
+}
diff --git a/dashboard/app/(app)/devices/SummaryCards.tsx b/dashboard/app/(app)/devices/SummaryCards.tsx
new file mode 100644
index 0000000..ef3a773
--- /dev/null
+++ b/dashboard/app/(app)/devices/SummaryCards.tsx
@@ -0,0 +1,156 @@
+/**
+ * Özet kartları — referans 2'nin üst şeridi (CPU · Bellek · Disk · Ağ).
+ *
+ * Referanstaki dört kart birebir alındı; içeriği uydurulmadı. Her sayı,
+ * hesabın cihazlarının EN SON ölçümlerinin ortalaması.
+ *
+ * Ortalamaya YALNIZCA konuşan cihazlar giriyor. Sebep: çevrimdışı bir cihazın
+ * son metriği saatler önceki bir andan kalma olabilir. Onu ortalamaya katmak,
+ * "şu anda ortalama CPU %12" derken aslında dün geceden kalma bir sayıyı
+ * karıştırmak olurdu. Kartın alt satırı kaç cihazın sayıldığını yazıyor —
+ * seyreltilmiş grafiğin "her nokta = X" satırıyla aynı dürüstlük kuralı
+ * (§9.6 madde 5): ekrandaki sayı, neyin ortalaması olduğunu kendisi söyler.
+ */
+"use client";
+
+import {
+ deviceStatus,
+ type Device,
+ type LatestMetric,
+} from "@/lib/devices";
+import {
+ FLUSH_THRESHOLD,
+ SERIES,
+ formatBitratePair,
+ formatPercent,
+} from "@/lib/metrics";
+import { IconCpu, IconDisk, IconMemory, IconNetwork } from "@/components/icons";
+
+/** null'ları atarak ortalama; hiç sayı yoksa null. */
+function mean(values: (number | null)[]): number | null {
+ const numbers = values.filter((v): v is number => v != null);
+ if (numbers.length === 0) return null;
+ return numbers.reduce((a, b) => a + b, 0) / numbers.length;
+}
+
+const ICONS = {
+ cpu: IconCpu,
+ ram: IconMemory,
+ disk: IconDisk,
+} as const;
+
+function Card({
+ icon,
+ chip,
+ label,
+ value,
+ hint,
+ bar,
+ barTone,
+}: {
+ icon: React.ReactNode;
+ chip: string;
+ label: string;
+ value: string;
+ hint: string;
+ /** 0–100; yoksa çubuk çizilmez (ağın yüzdesi olmaz). */
+ bar: number | null;
+ barTone: string;
+}) {
+ return (
+
+
+
+ {icon}
+
+ {label}
+
+
+
+ {value}
+
+
+ {bar != null && (
+
+
+
+ )}
+
+
{hint}
+
+ );
+}
+
+export function SummaryCards({
+ devices,
+ now,
+}: {
+ devices: Device[];
+ now: number;
+}) {
+ // "Konuşan" = çevrimdışı olmayan. Duraklatılmış cihaz da sayılıyor: pause
+ // yalnızca göndermeyi durdurur, agent komut poll'una devam eder ve son
+ // metriği tazedir (§7).
+ const live = devices.filter((d) => deviceStatus(d, now) !== "offline");
+ const metrics = live
+ .map((d) => d.latest)
+ .filter((m): m is LatestMetric => m != null);
+
+ const hint =
+ metrics.length === 0
+ ? "No reporting hosts"
+ : `Avg across ${metrics.length} hosts`;
+
+ const netSent = mean(metrics.map((m) => m.net_sent_mb));
+ const netRecv = mean(metrics.map((m) => m.net_recv_mb));
+ // Birim büyüklüğe göre seçiliyor (Mbps / Kbps), o yüzden sayıyla birlikte
+ // hesaplanıp alt satıra yazılıyor — bkz. formatBitratePair.
+ const net =
+ netSent == null || netRecv == null
+ ? null
+ : formatBitratePair(netSent, netRecv);
+
+ return (
+
+ {SERIES.map((series) => {
+ const Icon = ICONS[series.key];
+ const average = mean(
+ live.map((d) =>
+ d.latest ? series.percent(d.latest, d.ram_total_mb) : null,
+ ),
+ );
+ // Ortalama eşiği aşıyorsa çubuk kırmızı: tek bir cihazın değil,
+ // hesabın tamamının sıkıştığı anlamına gelir ve bu daha ciddi.
+ const over = average != null && average >= FLUSH_THRESHOLD[series.key];
+
+ return (
+ }
+ chip={series.tone.chip}
+ label={series.label}
+ value={average == null ? "—" : formatPercent(average)}
+ hint={hint}
+ bar={average ?? 0}
+ barTone={over ? "bg-danger" : series.tone.bar}
+ />
+ );
+ })}
+
+ }
+ chip="bg-net/10 text-net"
+ label="Network"
+ // Ağın tavanı yok: %90 dolu bir ağ kartı diye bir şey ölçmüyoruz.
+ // Bu yüzden çubuk da yok, ok işaretleriyle yön veriliyor.
+ value={net == null ? "—" : `↑${net.sent} ↓${net.recv}`}
+ hint={net == null ? hint : `${hint} · ${net.unit}`}
+ bar={null}
+ barTone=""
+ />
+
+ );
+}
diff --git a/dashboard/app/(app)/devices/[id]/DetailPanel.tsx b/dashboard/app/(app)/devices/[id]/DetailPanel.tsx
new file mode 100644
index 0000000..da20185
--- /dev/null
+++ b/dashboard/app/(app)/devices/[id]/DetailPanel.tsx
@@ -0,0 +1,371 @@
+/**
+ * Cihaz detayının SAĞ paneli (CLAUDE.md §9.4).
+ *
+ * Dar panelin işi tek: "hangi makineye bakıyorum, ne durumda, ne yapabilirim".
+ * Sayfa kaydırılırken yerinde kalır (sticky) — log listesinin dibine inen
+ * kullanıcı hangi cihazın loglarını okuduğunu unutmasın diye.
+ *
+ * Oran 70/30, referans görselin 55/45'inden bilinçli sapma: oradaki sağ panel
+ * bir form, bizimki iki buton ve künye. Buna karşılık log satırları uzun ve
+ * dar alanda kırpılıyor — geniş olması gereken taraf sol.
+ *
+ * Künye üç bloğa ayrıldı: DONANIM (değişmez), SİSTEM (yeniden kurulumla
+ * değişir), AGENT (sürekli değişir). Tek uzun liste olduğunda göz "RAM
+ * nerede" diye baştan taramak zorunda kalıyordu.
+ *
+ * Aksiyonlar (duraklat/devam/sil) burada, listede DEĞİL (§9.3): silme geri
+ * alınamaz bir iş ve kartların üstünde, yanlışlıkla tıklanacak bir yerde
+ * durmamalı.
+ *
+ * İkisi arasındaki fark §9.10'dan geliyor: duraklatma GERİ ALINABİLİR ve
+ * §9.10'un saydığı yıkıcı işlemler arasında yok, o yüzden onay penceresi
+ * istemiyor — her duraklatmada pencere açmak, pencerenin kendisini anlamsız
+ * bir refleks tuşuna çevirirdi. Silme tam pencereden geçiyor.
+ */
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useApp } from "@/lib/appState";
+import { queueCommand, type CommandType } from "@/lib/commands";
+import {
+ deviceStatus,
+ forceRemoveDevice,
+ isSilent,
+ type DeviceDetail,
+} from "@/lib/devices";
+import { gb, localDateTime, relativeTime } from "@/lib/time";
+import { errorMessage } from "@/lib/errors";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import { StatusPill } from "@/components/StatusPill";
+import {
+ IconPause,
+ IconPlay,
+ IconServer,
+ IconTrash,
+} from "@/components/icons";
+
+/**
+ * Hangi onay penceresi açık. Üç hâl tek değişkende çünkü İKİSİ AYNI ANDA
+ * AÇILAMAZ — iki ayrı boolean tutmak, ikisinin birden true olduğu imkânsız bir
+ * durumu temsil edilebilir kılardı.
+ */
+type Dialog = "none" | "delete" | "force";
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+