From 46f37e27f27970890e90dcb2f77c7353f8c99dc0 Mon Sep 17 00:00:00 2001 From: denis Date: Fri, 28 Aug 2026 20:47:53 +0300 Subject: [PATCH 01/13] =?UTF-8?q?feat(m9):=20dashboard=20iskeleti=20?= =?UTF-8?q?=E2=80=94=20Next.js=20+=20Tailwind=20+=20Supabase=20Auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M9'un ilk dilimi: veri akışı olmayan ama tesisatı çalışan bir Next.js uygulaması. Amaç, cihaz listesine geçmeden önce Auth → oturum → RLS'li okuma zincirinin ayakta olduğunu kanıtlamak. Mimari (CLAUDE.md §9.1): - Veriyi TARAYICI çeker. SSR ile veri çekilmez; Fly'ın işi yalnızca JS/HTML sunmak. Derleme çıktısında route'ların tamamı statik. Bunun iki sonucu var: instance kullanıcı sayısıyla büyümez ve Realtime (§9.9) mümkün olur. - Okuma doğrudan Supabase'den, user JWT + RLS ile. Collector'a okuma isteği gitmez. Kararlar: - Supabase istemcisi TEMBEL kuruluyor (lib/supabase.ts). Sayfalar "use client" olsa da Next derleme sırasında sunucuda bir kez çalıştırıp HTML iskeletini üretiyor; istemci modül yüklenirken kurulsaydı ortam değişkeni olmayan her derleme kırılırdı. Ayrıca tek örnek saklanıyor — ikinci bir örnek aynı localStorage anahtarını yazar, token yenilemede birbirini ezerlerdi. - Oturumun ÜÇ hâli var (lib/useSession.ts): loading / signedIn / signedOut. "Bilmiyorum" ile "giriş yapılmamış" birleştirilseydi sayfa her yenilemede bir an giriş ekranına atar, oturum okununca geri dönerdi. - Giriş formu bilerek süssüz (§9.2). §9.12'deki animasyonlu vitrin EN SON yapılacak; o zaman bu dosya baştan yazılır. - KAYIT ekranı YOK. İlk hesap Supabase panelinden elle açılır; kayıt akışı vitrinle birlikte tasarlanacak. - Görsel dil tokenleri globals.css'te bir kez tanımlı (§9.11); bileşenlerde ham renk kullanılmıyor — landing ile dashboard aynı dili konuşacak. - Giriş hatası "e-posta veya şifre hatalı" der. Supabase ikisini bilerek ayırmaz; ayırsaydı form bir hesap-var-mı sorgusuna dönerdi. .gitignore: barındırma Fly'a geçtiği için .vercel satırı kaldırıldı, yerine *.tsbuildinfo eklendi. --- .gitignore | 6 +- dashboard/.env.example | 13 + dashboard/README.md | 104 ++- dashboard/app/globals.css | 31 + dashboard/app/layout.tsx | 19 + dashboard/app/login/page.tsx | 118 +++ dashboard/app/page.tsx | 21 + dashboard/lib/supabase.ts | 53 ++ dashboard/lib/useSession.ts | 58 ++ dashboard/next.config.ts | 10 + dashboard/package-lock.json | 1698 ++++++++++++++++++++++++++++++++++ dashboard/package.json | 26 + dashboard/postcss.config.mjs | 6 + dashboard/tsconfig.json | 41 + 14 files changed, 2174 insertions(+), 30 deletions(-) create mode 100644 dashboard/.env.example create mode 100644 dashboard/app/globals.css create mode 100644 dashboard/app/layout.tsx create mode 100644 dashboard/app/login/page.tsx create mode 100644 dashboard/app/page.tsx create mode 100644 dashboard/lib/supabase.ts create mode 100644 dashboard/lib/useSession.ts create mode 100644 dashboard/next.config.ts create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/package.json create mode 100644 dashboard/postcss.config.mjs create mode 100644 dashboard/tsconfig.json diff --git a/.gitignore b/.gitignore index 7e7564d..a005d50 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,6 @@ Thumbs.db # Makinede duran, repoya girmeyen yardımcı dosyalar. CLAUDE.md md/ + +# --- YEREL ÖRNEK DASHBOARD GÖRSELLERİ ---------------------------------------- +dashboard/example 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/README.md b/dashboard/README.md index cc9e36f..ffc4c48 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -1,29 +1,75 @@ -# 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ı. +- Derleme çıktısının tamamı statik (`npm run build` → tüm route'lar `○ Static`). + +**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` | İskelet — oturum koruması çalışıyor, kartlar sonraki dilimde (§9.3) | +| `/` | 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 (iskelet) +├── lib/ +│ ├── supabase.ts # tek istemci, tembel kurulum +│ └── useSession.ts # loading / signedIn / signedOut — üçü AYRI hâl +├── 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 + +- **Deploy:** collector'dan **ayrı** bir Fly app olacak (§9.1). Dockerfile + fly.toml + cihaz listesi çalışır hâle gelince yazılır. +- **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/globals.css b/dashboard/app/globals.css new file mode 100644 index 0000000..edf4aa1 --- /dev/null +++ b/dashboard/app/globals.css @@ -0,0 +1,31 @@ +@import "tailwindcss"; + +/* + * Görsel dil — CLAUDE.md §9.11. + * Renkler burada BİR KEZ tanımlanır; bileşenlerde ham hex kullanılmaz. + * Sebep: landing (§9.12) ile dashboard aynı dili konuşacak. Renk her dosyaya + * elle yazılsaydı, marka tonu değiştiğinde onlarca yerde ayrı ayrı kayardı. + */ +@theme { + --color-bg: #0a0f1f; /* koyu lacivert zemin */ + --color-panel: #121a30; /* paneller: zeminden bir ton açık */ + --color-panel-2: #182241; /* panel içi vurgusuz yüzey (input, satır) */ + --color-line: #223052; /* ince, düşük kontrastlı çerçeve */ + --color-fg: #e6ebf5; /* normal metin */ + --color-muted: #8b97b0; /* ikincil metin */ + --color-accent: #6366f1; /* indigo — YALNIZCA aksiyon ve seçili durum */ + --color-accent-strong: #4f46e5; + --color-ok: #22c55e; + --color-warn: #f59e0b; + --color-danger: #ef4444; +} + +html, +body { + background: var(--color-bg); + color: var(--color-fg); +} + +body { + -webkit-font-smoothing: antialiased; +} diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx new file mode 100644 index 0000000..aeb9b6b --- /dev/null +++ b/dashboard/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "TraceBox", + description: "Makine çökmeden önceki anı başka bir cihazdan görebilmek.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/dashboard/app/login/page.tsx b/dashboard/app/login/page.tsx new file mode 100644 index 0000000..2a0ff87 --- /dev/null +++ b/dashboard/app/login/page.tsx @@ -0,0 +1,118 @@ +/** + * Ekran 1 — GEÇİCİ giriş formu (CLAUDE.md §9.2). + * + * Bilerek süssüz. §9.12'deki kara kutu animasyonlu vitrin EN SON yapılacak; + * bu dosya o zaman baştan yazılır. Buradaki tek iş, altındaki tesisatın + * (Supabase Auth → oturum → RLS'li okuma) gerçekten çalıştığını kanıtlamak. + * + * KAYIT (sign-up) EKRANI YOK — karar §9.2'de kilitli. İlk hesap Supabase + * panelinden elle açılır. Kayıt akışı vitrinle birlikte tasarlanacak + * ([[pending]] #10, #11, #12). + */ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { supabase } from "@/lib/supabase"; +import { useSession } from "@/lib/useSession"; + +export default function LoginPage() { + const { status } = useSession(); + const router = useRouter(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + // Giriş yapmış biri giriş ekranını görmemeli. + useEffect(() => { + if (status === "signedIn") router.replace("/devices"); + }, [status, router]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + + const { error } = await supabase().auth.signInWithPassword({ + email, + password, + }); + + if (error) { + // Supabase "e-posta yanlış" ile "şifre yanlış"ı bilerek AYIRMAZ; ayırsaydı + // form bir hesap-var-mı sorgusuna dönerdi. Türkçe karşılığı da aynı + // belirsizliği korur. + setError( + error.message === "Invalid login credentials" + ? "E-posta veya şifre hatalı." + : error.message, + ); + setBusy(false); + return; + } + // Başarılıysa yönlendirmeyi yukarıdaki useEffect yapar (oturum değişince). + } + + return ( +
+
+
+

TraceBox

+

+ Devam etmek için giriş yapın. +

+
+ +
+ + setEmail(e.target.value)} + className="mt-2 w-full rounded-lg border border-line bg-panel-2 px-3 py-2 outline-none focus:border-accent" + /> + + + setPassword(e.target.value)} + className="mt-2 w-full rounded-lg border border-line bg-panel-2 px-3 py-2 outline-none focus:border-accent" + /> + + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +} diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx new file mode 100644 index 0000000..97e4d53 --- /dev/null +++ b/dashboard/app/page.tsx @@ -0,0 +1,21 @@ +/** + * Kök adres bir ekran değil, bir yol ayrımı: oturum varsa cihaz listesine, + * yoksa giriş ekranına gönderir. Kendi başına hiçbir şey göstermez. + */ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "@/lib/useSession"; + +export default function Home() { + const { status } = useSession(); + const router = useRouter(); + + useEffect(() => { + if (status === "loading") return; // "bilmiyorum" hâlinde yönlendirme YOK + router.replace(status === "signedIn" ? "/devices" : "/login"); + }, [status, router]); + + return null; +} diff --git a/dashboard/lib/supabase.ts b/dashboard/lib/supabase.ts new file mode 100644 index 0000000..e0fe5d5 --- /dev/null +++ b/dashboard/lib/supabase.ts @@ -0,0 +1,53 @@ +/** + * Tarayıcının Supabase'e açılan tek kapısı. + * + * Neden fonksiyon, neden hazır bir nesne değil: bu sayfaların hepsi + * "use client" ama Next yine de derleme sırasında sunucuda bir kez çalıştırıp + * HTML iskeletini üretir. İstemci modül yüklenirken kurulsaydı, ortam + * değişkenleri olmayan her derleme kırılırdı. Fonksiyon, kurulumu ilk gerçek + * kullanıma erteler — yani tarayıcıya. + * + * İstemci **bir kez** kurulur ve saklanır. İkinci bir örnek kurulsaydı iki ayrı + * oturum yöneticisi aynı localStorage anahtarını yazar, token yenilemede + * birbirini ezerlerdi. + * + * Burada service key YOK: collector service key ile YAZAR (RLS bypass), + * dashboard anon key ile OKUR (RLS zorunlu) — CLAUDE.md §11 Boşluk D. + */ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +let cached: SupabaseClient | null = null; + +function required(name: string, value: string | undefined): string { + if (!value) { + // Sessizce undefined ile devam etmek en kötüsü: istemci kurulur, her + // sorgu anlamsız bir ağ hatasıyla döner ve sebebi görünmez olur. + throw new Error( + `Eksik ortam değişkeni: ${name}. dashboard/.env.example dosyasına bak.`, + ); + } + return value; +} + +export function supabase(): SupabaseClient { + if (cached) return cached; + + // NEXT_PUBLIC_* değişkenleri derleme anında koda gömülür; bu yüzden + // process.env erişimi tam adıyla yazılmalı (değişken adıyla okunamaz). + cached = createClient( + required("NEXT_PUBLIC_SUPABASE_URL", process.env.NEXT_PUBLIC_SUPABASE_URL), + required( + "NEXT_PUBLIC_SUPABASE_ANON_KEY", + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + ), + { + auth: { + // Oturum tarayıcıda saklanır ve süresi dolmadan kendini yeniler. + // SSR yok (§9.1), o yüzden sunucu tarafında çerez okumaya gerek yok. + persistSession: true, + autoRefreshToken: true, + }, + }, + ); + return cached; +} diff --git a/dashboard/lib/useSession.ts b/dashboard/lib/useSession.ts new file mode 100644 index 0000000..0dfb235 --- /dev/null +++ b/dashboard/lib/useSession.ts @@ -0,0 +1,58 @@ +/** + * "Bu tarayıcıda giriş yapılmış mı?" sorusunun tek cevabı. + * + * Üç hâl döner ve üçü de birbirinden AYRIDIR: + * status = "loading" → henüz bilmiyoruz (Supabase depodan oturumu okuyor) + * status = "signedIn" → oturum var + * status = "signedOut"→ oturum yok + * + * "Bilmiyorum" hâlini "giriş yapılmamış" ile birleştirmek klasik hatadır: + * sayfa bir an giriş ekranına atar, oturum okunduktan sonra geri döner — + * kullanıcı her yenilemede ekranın titrediğini görür. + */ +"use client"; + +import { useEffect, useState } from "react"; +import type { Session } from "@supabase/supabase-js"; +import { supabase } from "./supabase"; + +export type SessionState = + | { status: "loading"; session: null } + | { status: "signedIn"; session: Session } + | { status: "signedOut"; session: null }; + +export function useSession(): SessionState { + const [state, setState] = useState({ + status: "loading", + session: null, + }); + + useEffect(() => { + let alive = true; + + supabase().auth.getSession().then(({ data }) => { + if (!alive) return; + setState( + data.session + ? { status: "signedIn", session: data.session } + : { status: "signedOut", session: null }, + ); + }); + + // Çıkış, token yenileme ve başka sekmede yapılan giriş buradan gelir. + const { data: sub } = supabase().auth.onAuthStateChange((_event, session) => { + setState( + session + ? { status: "signedIn", session } + : { status: "signedOut", session: null }, + ); + }); + + return () => { + alive = false; + sub.subscription.unsubscribe(); + }; + }, []); + + return state; +} diff --git a/dashboard/next.config.ts b/dashboard/next.config.ts new file mode 100644 index 0000000..1db9bba --- /dev/null +++ b/dashboard/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + // Fly'da Docker ile çalışacak: standalone çıktı, node_modules'ün tamamını + // değil yalnızca gerçekten kullanılan dosyaları imaja koyar (küçük imaj, + // hızlı soğuk açılış — §9.12'deki uyku notu bu yüzden önemli). + output: "standalone", +}; + +export default nextConfig; diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..c7c570a --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,1698 @@ +{ + "name": "tracebox-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tracebox-dashboard", + "version": "0.1.0", + "dependencies": { + "@supabase/supabase-js": "^2.112.4", + "next": "^16.3.3", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^24.0.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.3.tgz", + "integrity": "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.3.tgz", + "integrity": "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.3.tgz", + "integrity": "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.3.tgz", + "integrity": "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.3.tgz", + "integrity": "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.3.tgz", + "integrity": "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.3.tgz", + "integrity": "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.3.tgz", + "integrity": "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.3.tgz", + "integrity": "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.4.tgz", + "integrity": "sha512-z8DesgwLzKM5PiT0yNmJU8VJyh1zAhYi+20Z7drdJQLXg/wWW4yGt/un+He5ERYUo94Vz66t5aeyr1DIDemI5A==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.4.tgz", + "integrity": "sha512-DQ0aVH8wSQAccVqNoEkec62qCu2QRNyoGN53RqsVZ1k6F1zq4/v8scrlR6LNT2RJmT97apiTmORijPVhErCS2g==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.112.4.tgz", + "integrity": "sha512-uaubtPSeg2TR4wrtfQoQWgkTAe+a0qWX2KhmwvTfNl5mGN9+U7owiJt6abk3o/V6O899PSRD1yzxs5RlF4xTug==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.112.4.tgz", + "integrity": "sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw==", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.112.4.tgz", + "integrity": "sha512-lQ0JemuTlMIXVKgSci1qez8yPnM5hyDngeAfEBjZS2Om4D+Cus0EE5BE6glFobrxdyii1OF4UzWfF0zcQgDq5A==", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.4.tgz", + "integrity": "sha512-UiCX1udlFY1fQQrO7Z3GU7obQsju0w5Vk9mOOwalfo/+Gy+tahWVenSSuu5E/GTy/q//HxvGv2IrCdW66/61kw==", + "dependencies": { + "@supabase/auth-js": "2.112.4", + "@supabase/functions-js": "2.112.4", + "@supabase/postgrest-js": "2.112.4", + "@supabase/realtime-js": "2.112.4", + "@supabase/storage-js": "2.112.4" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.3.tgz", + "integrity": "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==", + "dependencies": { + "@next/env": "16.3.3", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.3", + "@next/swc-darwin-x64": "16.3.3", + "@next/swc-linux-arm64-gnu": "16.3.3", + "@next/swc-linux-arm64-musl": "16.3.3", + "@next/swc-linux-x64-gnu": "16.3.3", + "@next/swc-linux-x64-musl": "16.3.3", + "@next/swc-win32-arm64-msvc": "16.3.3", + "@next/swc-win32-x64-msvc": "16.3.3", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..9a5fa77 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,26 @@ +{ + "name": "tracebox-dashboard", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@supabase/supabase-js": "^2.112.4", + "next": "^16.3.3", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^24.0.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.0" + } +} diff --git a/dashboard/postcss.config.mjs b/dashboard/postcss.config.mjs new file mode 100644 index 0000000..011b78d --- /dev/null +++ b/dashboard/postcss.config.mjs @@ -0,0 +1,6 @@ +// Tailwind v4 artık PostCSS eklentisini ayrı bir pakette sunuyor. +// Ayar dosyası (tailwind.config.js) YOK — tema doğrudan CSS içinde, +// app/globals.css'teki @theme bloğunda tanımlı. +export default { + plugins: { "@tailwindcss/postcss": {} }, +}; diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..5cbf295 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "ES2022" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} From 10db1bbf2653dd87d83a90e192fb3bc91502c6d6 Mon Sep 17 00:00:00 2001 From: denis Date: Fri, 28 Aug 2026 20:51:07 +0300 Subject: [PATCH 02/13] =?UTF-8?q?feat(m9):=20cihaz=20listesi=20ekran=C4=B1?= =?UTF-8?q?=20(=C2=A79.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ekran 2 çalışır hâlde: kart künyesi, dört durum rozeti ve üç ölçü çubuğu gerçek veriyle doğrulandı (VM'deki agent → collector → Supabase → tarayıcı). Veri yolu - Tek sorgu: devices + her cihazın EN SON metrik satırı. PostgREST'in gömülü kaynak sıralaması (`referencedTable`) sayesinde `limit(1)` her ana satır için ayrı ayrı uygulanır; N+1 sorgu yok. - Sorguda `account_id` filtresi bilerek YOK. RLS (`account_id = auth.uid()`) zaten satır bazında koruyor; elle filtre eklemek güvenlik katmaz, yalnızca "politika çalışıyor" yanılsaması yaratır. - Yenileme 10 sn (agent'ın komut poll aralığı). Ayrıca 1 sn'lik tick yalnızca "12 saniye önce" metnini tazeler — veri çekmez. Dört durum ve önceliği (deleting > offline > paused > online) - deleting: commands tablosunda type='delete' AND status='pending' - offline : last_seen 60 sn'den eski (agent 10 sn'de bir poll ediyor → üst üste 6 tur kaçırılmış demek; tesadüf olamayacak kadar uzun) - paused : devices.logging_enabled=false Öncelik sırası tesadüf değil: pause'da komut poll'ü DEVAM ettiği için last_seen taze kalır. Yani "duraklatılmış" bir cihaz aynı anda "çevrimdışı" görünemez; görünüyorsa gerçekten erişilemiyordur ve önemli olan bilgi budur. Çubuk renkleri Eşikler agent'ın flush eşiklerinden alındı (config.example.toml): CPU/RAM 90, disk 95. Ekranın kırmızıya döndüğü an ile agent'ın acil gönderim yaptığı an aynı olsun — iki ayrı "tehlike" tanımı kullanıcıyı yanıltırdı. Kartta aksiyon butonu yok (§9.3): silme geri alınamaz bir iş, listede yanlışlıkla tıklanacak yerde durmamalı. Kart yalnızca durum gösterir. "+ Cihaz Ekle" devre dışı: collector'da CORS middleware yok, tarayıcıdan POST /devices bugün bloklanır (§9.13). Buton gizlenmedi çünkü eksik olan özellik değil, tesisat — title ile sebebi yazıyor. /devices/[id] yer tutucu olarak eklendi; kart bağlantısı 404'e düşmesin. --- dashboard/README.md | 16 ++- dashboard/app/devices/DeviceCard.tsx | 140 +++++++++++++++++++++++++++ dashboard/app/devices/[id]/page.tsx | 33 +++++++ dashboard/app/devices/page.tsx | 125 ++++++++++++++++++++++++ dashboard/lib/devices.ts | 122 +++++++++++++++++++++++ dashboard/lib/time.ts | 28 ++++++ 6 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 dashboard/app/devices/DeviceCard.tsx create mode 100644 dashboard/app/devices/[id]/page.tsx create mode 100644 dashboard/app/devices/page.tsx create mode 100644 dashboard/lib/devices.ts create mode 100644 dashboard/lib/time.ts diff --git a/dashboard/README.md b/dashboard/README.md index ffc4c48..4b069d8 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -25,7 +25,8 @@ altında. | Yol | Durum | |---|---| | `/login` | **Geçici**, süssüz giriş formu. Kayıt ekranı YOK (§9.2) | -| `/devices` | İskelet — oturum koruması çalışıyor, kartlar sonraki dilimde (§9.3) | +| `/devices` | ✅ Cihaz kartları — künye, dört durum rozeti, üç ölçü çubuğu (§9.3) | +| `/devices/[id]` | Yer tutucu — detay ekranı sonraki dilimde (§9.4) | | `/` | 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; @@ -58,18 +59,25 @@ dashboard/ │ ├── 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 (iskelet) +│ └── devices/ +│ ├── page.tsx # ekran 2 — ızgara, 10 sn yenileme +│ ├── DeviceCard.tsx # kart: künye + rozet + üç çubuk +│ └── [id]/page.tsx # ekran 3 (yer tutucu) ├── lib/ │ ├── supabase.ts # tek istemci, tembel kurulum -│ └── useSession.ts # loading / signedIn / signedOut — üçü AYRI hâl +│ ├── useSession.ts # loading / signedIn / signedOut — üçü AYRI hâl +│ ├── devices.ts # sorgu + dört durumun türetilmesi +│ └── time.ts # "12 saniye önce", 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 +- **Cihaz detayı (§9.4)** — dört dilim: yerleşim + sağ panel, log listesi, + zaman çizelgesi (migration ister), aksiyonlar + onay penceresi. - **Deploy:** collector'dan **ayrı** bir Fly app olacak (§9.1). Dockerfile + fly.toml - cihaz listesi çalışır hâle gelince yazılır. + 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/devices/DeviceCard.tsx b/dashboard/app/devices/DeviceCard.tsx new file mode 100644 index 0000000..fe68699 --- /dev/null +++ b/dashboard/app/devices/DeviceCard.tsx @@ -0,0 +1,140 @@ +/** + * 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ı. + */ +"use client"; + +import Link from "next/link"; +import { + deviceStatus, + STATUS_LABEL, + type Device, + type DeviceStatus, +} from "@/lib/devices"; +import { gb, relativeTime } from "@/lib/time"; + +const STATUS_DOT: Record = { + online: "bg-ok", + offline: "bg-muted", + paused: "bg-warn", + deleting: "bg-danger", +}; + +/** + * Çubuk rengi eşikleri, agent'ın ACİL GÖNDERİM eşiklerinin aynısı + * (agent/config.example.toml: cpu 90, ram 90, disk 95). Böylece kırmızı çubuk + * keyfi bir tasarım kararı değil, tam olarak şu anlama gelir: "agent bu değeri + * acil sayıp anında flush ederdi". Sarı, ona yaklaşıldığını gösteren erken uyarı. + */ +function barColor(percent: number, danger: number): string { + if (percent >= danger) return "bg-danger"; + if (percent >= danger - 15) return "bg-warn"; + return "bg-muted"; +} + +function Bar({ + label, + percent, + value, + danger, +}: { + label: string; + percent: number | null; + value: string; + danger: number; +}) { + const pct = percent == null ? null : Math.min(100, Math.max(0, percent)); + + return ( +
+ {label} +
+ {pct != null && ( +
+ )} +
+ + {value} + +
+ ); +} + +export function DeviceCard({ device, now }: { device: Device; now: number }) { + const status = deviceStatus(device, now); + const m = device.latest; + + const ramPercent = + m?.ram_used_mb != null && device.ram_total_mb + ? (m.ram_used_mb / device.ram_total_mb) * 100 + : null; + + return ( + +
+
+
+ +

{device.device_name}

+
+

+ {[ + [device.os_name, device.os_version].filter(Boolean).join(" "), + device.cpu_cores_logical + ? `${device.cpu_cores_logical} çekirdek` + : null, + ] + .filter(Boolean) + .join(" · ") || "envanter bekleniyor"} +

+
+ + {STATUS_LABEL[status]} + +
+ +
+ + + +
+ +

+ {relativeTime(device.last_seen, now)} görüldü +

+ + ); +} diff --git a/dashboard/app/devices/[id]/page.tsx b/dashboard/app/devices/[id]/page.tsx new file mode 100644 index 0000000..5ed4e0f --- /dev/null +++ b/dashboard/app/devices/[id]/page.tsx @@ -0,0 +1,33 @@ +/** + * Ekran 3 — Cihaz detayı (CLAUDE.md §9.4). + * + * YER TUTUCU: 70/30 yerleşim, zaman çizelgesi, log listesi ve pause/sil + * butonları bir sonraki dilimde gelir. Şimdilik yalnızca route param'ının + * çalıştığını ve kartın kırık bağlantıya gitmediğini gösterir. + */ +"use client"; + +import { use } from "react"; +import Link from "next/link"; + +export default function DeviceDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + + return ( +
+ + ← Cihazlar + +
+

+ Cihaz detayı bir sonraki dilimde gelecek (§9.4). +

+

{id}

+
+
+ ); +} diff --git a/dashboard/app/devices/page.tsx b/dashboard/app/devices/page.tsx new file mode 100644 index 0000000..e34f353 --- /dev/null +++ b/dashboard/app/devices/page.tsx @@ -0,0 +1,125 @@ +/** + * Ekran 2 — Cihaz listesi (CLAUDE.md §9.3). + * + * Cevapladığı tek soru: "makinelerim iyi mi?" + * İki panel bölünmesi YOK (sabit bağlam olmadığı için); kartlar ızgarada. + */ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { supabase } from "@/lib/supabase"; +import { useSession } from "@/lib/useSession"; +import { fetchDevices, type Device } from "@/lib/devices"; +import { DeviceCard } from "./DeviceCard"; + +/** + * Liste 10 saniyede bir yenilenir — agent'ın komut poll'u ile aynı ritim. + * Daha sık sormanın anlamı yok: last_seen zaten en fazla o kadar tazeleniyor. + * + * Burada Realtime KULLANILMIYOR. Realtime, son 24 saatin logları için + * ayrıldı (§9.9); orada gecikme ürünün vaadinin parçası. Liste ekranında ise + * "12 saniye önce" yazısının bir saniye geç güncellenmesi kimseyi ilgilendirmez, + * buna karşılık her cihaz için ayrı bir abonelik açmak gerekirdi. + */ +const REFRESH_MS = 10_000; + +export default function DevicesPage() { + const { status, session } = useSession(); + const router = useRouter(); + + const [devices, setDevices] = useState(null); + const [error, setError] = useState(null); + + // Saniyede bir tikleyen "şu an". Kartlardaki "12 saniye önce" ve çevrimdışı + // rozeti bunun üzerinden hesaplanır — veriyi yeniden çekmeye gerek kalmadan + // sayaç ilerler. + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (status === "signedOut") router.replace("/login"); + }, [status, router]); + + const load = useCallback(async () => { + try { + setDevices(await fetchDevices()); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + if (status !== "signedIn") return; + load(); + const refresh = setInterval(load, REFRESH_MS); + const tick = setInterval(() => setNow(Date.now()), 1000); + return () => { + clearInterval(refresh); + clearInterval(tick); + }; + }, [status, load]); + + if (status !== "signedIn") { + return ( +
+ Yükleniyor… +
+ ); + } + + return ( +
+
+
+

Cihazlar

+

+ {session.user.email} +

+
+
+ + +
+
+ + {error && ( +

+ Cihazlar okunamadı: {error} +

+ )} + + {devices === null && !error && ( +

Yükleniyor…

+ )} + + {devices?.length === 0 && ( +
+

Henüz cihaz yok.

+

+ Bir cihaz eklendiğinde ve agent kurulduğunda burada görünür. +

+
+ )} + + {devices && devices.length > 0 && ( +
+ {devices.map((device) => ( + + ))} +
+ )} +
+ ); +} diff --git a/dashboard/lib/devices.ts b/dashboard/lib/devices.ts new file mode 100644 index 0000000..ec821f1 --- /dev/null +++ b/dashboard/lib/devices.ts @@ -0,0 +1,122 @@ +/** + * Cihaz listesinin veri katmanı (CLAUDE.md §9.3). + * + * Kartın gösterdiği bilgi üç ayrı yerden gelir: + * künye ............ devices satırı + * üç ölçü .......... o cihazın EN SON metrics satırı + * "silinme bekliyor" commands tablosu (type='delete', status='pending') + * + * Hepsi RLS altında: sorgular account_id filtresi YAZMAZ, çünkü politika + * (`account_id = auth.uid()`) zaten satır bazında süzüyor. Elle filtre yazmak + * güvenlik eklemez, sadece politikanın çalıştığı yanılsaması yaratır. + */ +import { supabase } from "./supabase"; + +/** + * Çevrimdışı eşiği — §9.3'te kilitli. Agent 10 saniyede bir komut soruyor ve + * her soruş last_seen'i tazeliyor; 60 saniye "üst üste 6 tur kaçırıldı" demek. + * Tesadüf olamayacak kadar uzun, fark etmeyi geciktirmeyecek kadar kısa. + */ +export const OFFLINE_AFTER_SECONDS = 60; + +export type LatestMetric = { + measured_at: string; + cpu_percent: number | null; + ram_used_mb: number | null; + disk_percent: number | null; +}; + +export type DeviceRow = { + id: string; + device_name: string; + os_name: string | null; + os_version: string | null; + arch: string | null; + cpu_cores_logical: number | null; + ram_total_mb: number | null; + disk_total_mb: number | null; + agent_version: string | null; + logging_enabled: boolean; + last_seen: string | null; + metrics: LatestMetric[]; // gömülü sorgu her zaman dizi döner: 0 ya da 1 satır +}; + +export type Device = Omit & { + latest: LatestMetric | null; + deletePending: boolean; +}; + +export type DeviceStatus = "online" | "offline" | "paused" | "deleting"; + +/** + * Dört hâlin SIRASI önemli — bir cihaz aynı anda birkaçına uyabilir. + * + * 1. deleting — silme emri verilmiş; her şeyin üstünde, çünkü cihazın geleceği + * hakkında söylenecek en önemli şey bu. + * 2. offline — makine konuşmuyor. Duraklatmanın önünde geliyor, çünkü pause + * sırasında agent komut poll'una DEVAM eder (§7) ve last_seen tazelenir. + * Yani "hem duraklatılmış hem sessiz" bir cihazda asıl haber sessizliktir. + * 3. paused — kullanıcının kendi kararı, makine sağlıklı. + * 4. online — geriye kalan. + */ +export function deviceStatus(device: Device, now: number): DeviceStatus { + if (device.deletePending) return "deleting"; + + const stale = + device.last_seen == null || + now - Date.parse(device.last_seen) > OFFLINE_AFTER_SECONDS * 1000; + if (stale) return "offline"; + + if (!device.logging_enabled) return "paused"; + return "online"; +} + +export const STATUS_LABEL: Record = { + online: "Çevrimiçi", + offline: "Çevrimdışı", + paused: "Duraklatıldı", + deleting: "Silinme bekliyor", +}; + +export async function fetchDevices(): Promise { + const client = supabase(); + + // Tek sorguda cihaz + o cihazın son metriği. PostgREST gömülü kaynağa + // ayrı order/limit uygulayabiliyor; limit HER ana satır için ayrı işler, + // yani "cihaz başına 1 metrik" demek. (device_id, measured_at) indeksi + // zaten var, sorgu o indeksin üstünden yürür. + const devicesQuery = client + .from("devices") + .select( + `id, device_name, os_name, os_version, arch, cpu_cores_logical, + ram_total_mb, disk_total_mb, agent_version, logging_enabled, last_seen, + metrics ( measured_at, cpu_percent, ram_used_mb, disk_percent )`, + ) + .order("measured_at", { referencedTable: "metrics", ascending: false }) + .limit(1, { referencedTable: "metrics" }) + .order("device_name"); + + // Bekleyen silme emirleri ayrı sorgu. Gömülü de çekilebilirdi ama gömülü + // kaynağa filtre uygulamak sorguyu okunmaz hale getiriyor; satır sayısı + // (hesap başına bekleyen komut) her zaman avuç içi kadar. + const commandsQuery = client + .from("commands") + .select("device_id") + .eq("type", "delete") + .eq("status", "pending"); + + const [devices, commands] = await Promise.all([devicesQuery, commandsQuery]); + + if (devices.error) throw devices.error; + if (commands.error) throw commands.error; + + const pending = new Set((commands.data ?? []).map((c) => c.device_id)); + + return (devices.data as unknown as DeviceRow[]).map( + ({ metrics, ...device }) => ({ + ...device, + latest: metrics[0] ?? null, + deletePending: pending.has(device.id), + }), + ); +} diff --git a/dashboard/lib/time.ts b/dashboard/lib/time.ts new file mode 100644 index 0000000..abb15db --- /dev/null +++ b/dashboard/lib/time.ts @@ -0,0 +1,28 @@ +/** + * "12 saniye önce" — kartın alt satırındaki insan okunur zaman. + * + * `now` dışarıdan verilir, içeride Date.now() çağrılmaz. Sebep iki tane: + * bileşen saniyede bir tik atıp aynı fonksiyonu yeniden çağırdığında tüm + * kartlar AYNI ana göre hesaplanır (biri 11, diğeri 12 saniye demez), ve + * fonksiyon test edilebilir kalır. + */ +export function relativeTime(iso: string | null, now: number): string { + if (!iso) return "hiç görülmedi"; + + const seconds = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000)); + if (seconds < 60) return `${seconds} saniye önce`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} dakika önce`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} saat önce`; + + return `${Math.floor(hours / 24)} gün önce`; +} + +/** 16384 → "16.0 GB". Kartta MB yazmak okunaksız. */ +export function gb(mb: number | null | undefined): string { + if (mb == null) return "—"; + return `${(mb / 1024).toFixed(1)} GB`; +} From 63af364ab20dec716fe0a0194cb5098e2a274915 Mon Sep 17 00:00:00 2001 From: denis Date: Sat, 29 Aug 2026 13:46:16 +0300 Subject: [PATCH 03/13] =?UTF-8?q?feat(m9):=20cihaz=20detay=C4=B1=20?= =?UTF-8?q?=E2=80=94=2070/30=20yerle=C5=9Fim,=20k=C3=BCnye=20paneli=20ve?= =?UTF-8?q?=20log=20listesi=20(=C2=A79.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Üçüncü ekranın ilk iki dilimi: geniş sol panel inceleme alanı, kaydırmada yerinde kalan dar sağ panel. DetailPanel envanter künyesini ve durum rozetini gösteriyor; pause ve sil butonları bilerek devre dışı, çünkü §9.10 her yıkıcı işlem için onay penceresi şart koşuyor ve o pencere yazılmadan butonu canlı bırakmak kuralı kâğıt üstünde bırakırdı. LogList veriyi bölüm 9.5 içindeki blok birimiyle çekiyor: blok bir takvim günü, sınırı 00:00 UTC. Retention da UTC üzerinden sildiği için iki ayrı gün tanımı olsaydı kapandı sanılan bloğun içi değişebilirdi. Blok kullanıcıya hiç gösterilmiyor; ekranda yalnızca son X aralıkları ile yerel saat var. Seviye süzgeci tam eşleşme değil EŞİK olarak yorumlandı: warning seçiliyken error gizlenmemeli. Sayfalama iki yarış koşuluna karşı korunuyor; eşzamanlılık kilidi çift çekimi, epoch sayacı ise süzgeç değişince uçuşta kalan eski yanıtın listeye eklenmesini engelliyor. Timeline şimdilik yalnızca aralık düğmelerini taşıyor, çünkü grafik bölüm 9.7 içindeki seyreltme fonksiyonunu bekliyor ve o fonksiyon da bir migration istiyor; aralık tek ve ortak olduğu için (§9.8) düğmeler yine de baştan buraya kondu. fetchDevice geçersiz UUID değerini hataya değil 404 sonucuna düşürüyor ve null dönüşü yok anlamına gelmiyor, sana görünmüyor anlamına geliyor: RLS başkasının cihazını da tam olarak böyle gizler, ikisini ayırmak sızıntı olurdu. Bölüm 9.5 içindeki blok tahliyesi bu dilimde UYGULANMADI, çünkü yarım uygulaması kaydırma çubuğunu zıplatır ve düzgünü sanallaştırma ister. README hem bunu hem de tüm route katmanının statik olduğunu söyleyen eski cümlenin düzeltmesini kaydediyor; /devices/[id] dinamik segment olduğu için derlemede ƒ görünüyor ama sunucunun ürettiği şey boş bir kabuk. --- dashboard/README.md | 26 ++- dashboard/app/devices/[id]/DetailPanel.tsx | 124 ++++++++++++ dashboard/app/devices/[id]/LogList.tsx | 210 +++++++++++++++++++++ dashboard/app/devices/[id]/Timeline.tsx | 51 +++++ dashboard/app/devices/[id]/page.tsx | 124 ++++++++++-- dashboard/lib/devices.ts | 78 +++++++- dashboard/lib/logs.ts | 130 +++++++++++++ dashboard/lib/time.ts | 28 +++ 8 files changed, 748 insertions(+), 23 deletions(-) create mode 100644 dashboard/app/devices/[id]/DetailPanel.tsx create mode 100644 dashboard/app/devices/[id]/LogList.tsx create mode 100644 dashboard/app/devices/[id]/Timeline.tsx create mode 100644 dashboard/lib/logs.ts diff --git a/dashboard/README.md b/dashboard/README.md index 4b069d8..c07c0c7 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -10,7 +10,9 @@ doğrudan Supabase'e bağlanır. Fly'ın işi yalnızca JS/HTML sunmak (§9.1). - 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ı. -- Derleme çıktısının tamamı statik (`npm run build` → tüm route'lar `○ Static`). +- **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.** @@ -26,7 +28,7 @@ altında. |---|---| | `/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]` | Yer tutucu — detay ekranı sonraki dilimde (§9.4) | +| `/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; @@ -62,20 +64,30 @@ dashboard/ │ └── devices/ │ ├── page.tsx # ekran 2 — ızgara, 10 sn yenileme │ ├── DeviceCard.tsx # kart: künye + rozet + üç çubuk -│ └── [id]/page.tsx # ekran 3 (yer tutucu) +│ └── [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 # sorgu + dört durumun türetilmesi -│ └── time.ts # "12 saniye önce", MB→GB +│ ├── 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 -- **Cihaz detayı (§9.4)** — dört dilim: yerleşim + sağ panel, log listesi, - zaman çizelgesi (migration ister), aksiyonlar + onay penceresi. +- **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 diff --git a/dashboard/app/devices/[id]/DetailPanel.tsx b/dashboard/app/devices/[id]/DetailPanel.tsx new file mode 100644 index 0000000..eef81a7 --- /dev/null +++ b/dashboard/app/devices/[id]/DetailPanel.tsx @@ -0,0 +1,124 @@ +/** + * 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. + */ +"use client"; + +import { + deviceStatus, + STATUS_LABEL, + type DeviceDetail, + type DeviceStatus, +} from "@/lib/devices"; +import { gb, localDateTime, relativeTime } from "@/lib/time"; + +const STATUS_DOT: Record = { + online: "bg-ok", + offline: "bg-muted", + paused: "bg-warn", + deleting: "bg-danger", +}; + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +export function DetailPanel({ + device, + now, +}: { + device: DeviceDetail; + now: number; +}) { + const status = deviceStatus(device, now); + + return ( + + ); +} diff --git a/dashboard/app/devices/[id]/LogList.tsx b/dashboard/app/devices/[id]/LogList.tsx new file mode 100644 index 0000000..1250b16 --- /dev/null +++ b/dashboard/app/devices/[id]/LogList.tsx @@ -0,0 +1,210 @@ +/** + * Log listesi (CLAUDE.md §9.4 + §9.5). + * + * Kaydırdıkça artımlı gelir: önce en yeni blok, o bittikçe bir öncesi. + * Kullanıcı dibe DEĞMEDEN önden çekilir (sentinel, viewport'un ~600px altında) + * — dibe değince yüklemek, her seferinde görünür bir duraklama demek olurdu. + * + * Blok mantığının tamamı lib/logs.ts'te; buradaki iş yalnızca "sırada hangi + * sayfa var" defterini tutmak. + */ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + LEVEL_FILTER_LABEL, + blocksForRange, + fetchLogPage, + type LevelFilter, + type LogLevel, + type LogRow, +} from "@/lib/logs"; +import { logTime } from "@/lib/time"; + +const LEVEL_STYLE: Record = { + info: "text-muted", + warning: "text-warn", + error: "text-danger", + critical: "text-danger font-semibold", +}; + +const FILTERS: LevelFilter[] = ["all", "warning", "error"]; + +export function LogList({ + deviceId, + rangeSeconds, + anchor, + now, +}: { + deviceId: string; + /** Seçili aralığın uzunluğu (§9.5). */ + rangeSeconds: number; + /** + * Aralığın hesaplandığı an. Saniyede bir tikleyen "now" DEĞİL — o olsaydı + * aralığın alt sınırı her saniye kayar, listenin tamamı sürekli yeniden + * çekilirdi. Yalnızca kullanıcı aralığı değiştirdiğinde tazelenir. + */ + anchor: number; + now: number; +}) { + const [level, setLevel] = useState("all"); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [finished, setFinished] = useState(false); + const [error, setError] = useState(null); + + const blocks = useMemo( + () => blocksForRange(anchor, rangeSeconds), + [anchor, rangeSeconds], + ); + const rangeStartMs = anchor - rangeSeconds * 1000; + + // Defter: kaçıncı bloktayız, o blokta kaç satır geçtik. Render'ı + // ilgilendirmediği için state değil ref — her sayfada yeniden çizim olmaz. + const cursor = useRef({ block: 0, offset: 0 }); + const scroller = useRef(null); + const sentinel = useRef(null); + + // Eşzamanlılık kilidi state DEĞİL ref: state güncellemesi asenkron, iki + // sentinel tetiklemesi arka arkaya gelirse ikisi de "boşta" görür ve aynı + // sayfa iki kez inerdi. + const busy = useRef(false); + + /** + * Süzgeç, uçuştaki bir istekten daha hızlı değiştirilebilir. Epoch olmadan + * "hepsi" için başlamış bir sayfa, kullanıcı "error"a geçtikten sonra dönüp + * info satırlarını listeye ekleyebilirdi — ekran süzgecin yalan söylediğini + * gösterirdi. Yanıt, başladığı epoch hâlâ geçerliyse uygulanır. + */ + const epoch = useRef(0); + + // Cihaz / aralık / süzgeç değişti → defter başa sarılır. + useEffect(() => { + epoch.current += 1; + cursor.current = { block: 0, offset: 0 }; + busy.current = false; + setRows([]); + setFinished(false); + setError(null); + scroller.current?.scrollTo({ top: 0 }); + }, [deviceId, level, blocks]); + + const loadMore = useCallback(async () => { + if (busy.current) return; + busy.current = true; + setLoading(true); + + const mine = epoch.current; + try { + // Boş bloklar sessizce atlanır: 10 günün 7'sinde hiç error yoksa + // kullanıcının 7 kez daha kaydırması gerekmemeli. + while (cursor.current.block < blocks.length) { + const { block, offset } = cursor.current; + const page = await fetchLogPage({ + deviceId, + block: blocks[block], + level, + offset, + rangeStartMs, + }); + if (mine !== epoch.current) return; // sorgu eskidi, yanıtı at + + cursor.current = page.blockDone + ? { block: block + 1, offset: 0 } + : { block, offset: offset + page.rows.length }; + + if (page.rows.length > 0) { + setRows((prev) => [...prev, ...page.rows]); + break; + } + } + if (cursor.current.block >= blocks.length) setFinished(true); + setError(null); + } catch (e) { + if (mine !== epoch.current) return; + setError(e instanceof Error ? e.message : String(e)); + setFinished(true); // durmadan yeniden denemek hatayı çoğaltmaktan başka işe yaramaz + } finally { + if (mine === epoch.current) busy.current = false; + setLoading(false); + } + }, [deviceId, level, blocks, rangeStartMs]); + + useEffect(() => { + const target = sentinel.current; + if (!target || finished) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) loadMore(); + }, + { root: scroller.current, rootMargin: "600px" }, + ); + observer.observe(target); + return () => observer.disconnect(); + }, [loadMore, finished, rows.length]); + + return ( +
+
+

Loglar

+ {FILTERS.map((f) => ( + + ))} +
+ +
+ {error && ( +

+ Loglar okunamadı: {error} +

+ )} + + {rows.length === 0 && finished && !error && ( +

+ Bu aralıkta log yok. +

+ )} + +
    + {rows.map((row) => ( +
  • + + {logTime(row.measured_at, now)} + + + {row.level} + + + {row.source ?? "—"} + + + {row.message} + +
  • + ))} +
+ + {/* Sentinel: görünür alana yaklaşınca bir sonraki sayfayı tetikler. */} +
+ + {loading && ( +

Yükleniyor…

+ )} +
+
+ ); +} diff --git a/dashboard/app/devices/[id]/Timeline.tsx b/dashboard/app/devices/[id]/Timeline.tsx new file mode 100644 index 0000000..74a701f --- /dev/null +++ b/dashboard/app/devices/[id]/Timeline.tsx @@ -0,0 +1,51 @@ +/** + * Zaman çizelgesi paneli (CLAUDE.md §9.6–§9.8). + * + * Grafiğin kendisi HENÜZ YOK: §9.7'deki seyreltme (aralığı ~1000 kovaya bölüp + * her kovadan min/max/ortalama döndüren SQL fonksiyonu) bir migration + * gerektiriyor ve migration'ı kullanıcı uyguluyor. Grafiği o fonksiyon olmadan + * çizmek, 173.000 ham satırı tarayıcıya indirmek demekti. + * + * Aralık düğmeleri buna rağmen BUGÜN burada duruyor, çünkü aralık tek ve + * ortaktır (§9.8): grafikteki seçim log listesini de daraltır. Düğmeleri şimdi + * log paneline koyup sonra taşımak, aralığın kime ait olduğu konusunda yanlış + * bir alışkanlık kurardı. + */ +"use client"; + +import { RANGES, type RangeKey } from "@/lib/logs"; + +export function Timeline({ + range, + onRangeChange, +}: { + range: RangeKey; + onRangeChange: (key: RangeKey) => void; +}) { + return ( +
+
+

Zaman çizelgesi

+ {RANGES.map((r) => ( + + ))} +
+ +
+

+ Grafik bir sonraki dilimde gelecek (§9.7). +
+ Seyreltme fonksiyonu için bir migration gerekiyor. +

+
+
+ ); +} diff --git a/dashboard/app/devices/[id]/page.tsx b/dashboard/app/devices/[id]/page.tsx index 5ed4e0f..41bfc9d 100644 --- a/dashboard/app/devices/[id]/page.tsx +++ b/dashboard/app/devices/[id]/page.tsx @@ -1,14 +1,25 @@ /** * Ekran 3 — Cihaz detayı (CLAUDE.md §9.4). * - * YER TUTUCU: 70/30 yerleşim, zaman çizelgesi, log listesi ve pause/sil - * butonları bir sonraki dilimde gelir. Şimdilik yalnızca route param'ının - * çalıştığını ve kartın kırık bağlantıya gitmediğini gösterir. + * Cevapladığı soru: "bu makinede ne oldu?" + * + * Yerleşim 70/30: geniş sol panel inceleme alanı (çizelge + loglar), dar sağ + * panel sabit bağlam ve aksiyonlar. Sağ panel kaydırmada yerinde kalır. */ "use client"; -import { use } from "react"; +import { use, useCallback, useEffect, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "@/lib/useSession"; +import { fetchDevice, type DeviceDetail } from "@/lib/devices"; +import { RANGES, type RangeKey } from "@/lib/logs"; +import { DetailPanel } from "./DetailPanel"; +import { Timeline } from "./Timeline"; +import { LogList } from "./LogList"; + +/** Künye yenileme — liste ekranıyla aynı ritim, agent'ın komut poll'ü. */ +const REFRESH_MS = 10_000; export default function DeviceDetailPage({ params, @@ -16,18 +27,109 @@ export default function DeviceDetailPage({ params: Promise<{ id: string }>; }) { const { id } = use(params); + const { status } = useSession(); + const router = useRouter(); + + const [device, setDevice] = useState(null); + const [missing, setMissing] = useState(false); + const [error, setError] = useState(null); + const [now, setNow] = useState(() => Date.now()); + + const [range, setRange] = useState("24h"); + + /** + * Aralığın sabitlendiği an. Saniyede bir tikleyen `now` kullanılsaydı + * aralığın alt sınırı her saniye kayar, log listesi durmadan sıfırlanırdı. + * Yalnızca aralık değiştiğinde tazelenir. + */ + const [anchor, setAnchor] = useState(() => Date.now()); + + const selectRange = useCallback((key: RangeKey) => { + setRange(key); + setAnchor(Date.now()); + }, []); + + useEffect(() => { + if (status === "signedOut") router.replace("/login"); + }, [status, router]); + + const load = useCallback(async () => { + try { + const row = await fetchDevice(id); + if (row) setDevice(row); + else setMissing(true); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [id]); + + useEffect(() => { + if (status !== "signedIn") return; + load(); + const refresh = setInterval(load, REFRESH_MS); + const tick = setInterval(() => setNow(Date.now()), 1000); + return () => { + clearInterval(refresh); + clearInterval(tick); + }; + }, [status, load]); + + if (status !== "signedIn") { + return ( +
+ Yükleniyor… +
+ ); + } + + const rangeSeconds = + RANGES.find((r) => r.key === range)?.seconds ?? RANGES[0].seconds; return ( -
- +
+ ← Cihazlar -
-

- Cihaz detayı bir sonraki dilimde gelecek (§9.4). + + {error && ( +

+ Cihaz okunamadı: {error}

-

{id}

-
+ )} + + {/* + "Bulunamadı" ile "senin değil" ayrı ayrı yazılmıyor: RLS başkasının + cihazını da tam olarak bu şekilde gizliyor ve ikisini ayırmak, olmayan + bir cihazın var olduğunu doğrulamak olurdu. + */} + {missing && ( +
+

Cihaz bulunamadı.

+

+ Silinmiş olabilir ya da bu hesaba ait olmayabilir. +

+
+ )} + + {!device && !missing && !error && ( +

Yükleniyor…

+ )} + + {device && ( +
+
+ + +
+ +
+ )}
); } diff --git a/dashboard/lib/devices.ts b/dashboard/lib/devices.ts index ec821f1..a733ee4 100644 --- a/dashboard/lib/devices.ts +++ b/dashboard/lib/devices.ts @@ -78,6 +78,17 @@ export const STATUS_LABEL: Record = { deleting: "Silinme bekliyor", }; +/** + * Kart için gereken en küçük sütun kümesi + gömülü son metrik. + * Detay ekranı (§9.4) bunun üstüne envanterin kalanını ekler. + */ +const LIST_COLUMNS = `id, device_name, os_name, os_version, arch, cpu_cores_logical, + ram_total_mb, disk_total_mb, agent_version, logging_enabled, last_seen, + metrics ( measured_at, cpu_percent, ram_used_mb, disk_percent )`; + +const DETAIL_COLUMNS = `${LIST_COLUMNS}, cpu_model, cpu_cores_physical, + kernel_version, last_boot, gpu_model, external_ip, enabled_addons`; + export async function fetchDevices(): Promise { const client = supabase(); @@ -87,11 +98,7 @@ export async function fetchDevices(): Promise { // zaten var, sorgu o indeksin üstünden yürür. const devicesQuery = client .from("devices") - .select( - `id, device_name, os_name, os_version, arch, cpu_cores_logical, - ram_total_mb, disk_total_mb, agent_version, logging_enabled, last_seen, - metrics ( measured_at, cpu_percent, ram_used_mb, disk_percent )`, - ) + .select(LIST_COLUMNS) .order("measured_at", { referencedTable: "metrics", ascending: false }) .limit(1, { referencedTable: "metrics" }) .order("device_name"); @@ -120,3 +127,64 @@ export async function fetchDevices(): Promise { }), ); } + +/* --- Cihaz detayı (§9.4) -------------------------------------------------- */ + +/** Sağ paneldeki künye — listede gereksiz olan envanter alanları. */ +export type DeviceDetail = Device & { + cpu_model: string | null; + cpu_cores_physical: number | null; + kernel_version: string | null; + last_boot: string | null; + gpu_model: string | null; + external_ip: string | null; + enabled_addons: string[]; +}; + +/** Postgres: geçersiz UUID metni. Uydurulmuş bir URL hataya değil, 404'e düşmeli. */ +const INVALID_TEXT_REPRESENTATION = "22P02"; + +/** + * Tek cihaz + son metriği + bekleyen silme emri. + * + * `null` dönmesi "yok" demek DEĞİL, "sana görünmüyor" demek: RLS başkasının + * cihazını da tam olarak böyle gizler. Ekranda ikisini ayırmaya çalışmıyoruz, + * çünkü ayırmak zaten bir bilgi sızıntısı olurdu. + */ +export async function fetchDevice(id: string): Promise { + const client = supabase(); + + const deviceQuery = client + .from("devices") + .select(DETAIL_COLUMNS) + .eq("id", id) + .order("measured_at", { referencedTable: "metrics", ascending: false }) + .limit(1, { referencedTable: "metrics" }) + .maybeSingle(); + + const commandQuery = client + .from("commands") + .select("id") + .eq("device_id", id) + .eq("type", "delete") + .eq("status", "pending") + .limit(1); + + const [device, commands] = await Promise.all([deviceQuery, commandQuery]); + + if (device.error) { + if (device.error.code === INVALID_TEXT_REPRESENTATION) return null; + throw device.error; + } + if (!device.data) return null; + if (commands.error) throw commands.error; + + const { metrics, ...rest } = device.data as unknown as DeviceRow & + Omit; + + return { + ...rest, + latest: metrics[0] ?? null, + deletePending: (commands.data ?? []).length > 0, + }; +} diff --git a/dashboard/lib/logs.ts b/dashboard/lib/logs.ts new file mode 100644 index 0000000..8c4b371 --- /dev/null +++ b/dashboard/lib/logs.ts @@ -0,0 +1,130 @@ +/** + * Log listesinin veri katmanı (CLAUDE.md §9.4 + §9.5). + * + * Buradaki tek büyük fikir: veri BLOK BLOK çekilir. Blok = bir takvim günü, + * sınırı 00:00 UTC. Kapanmış bir günün verisi bir daha asla değişmez, yani + * süresiz önbelleklenebilir — "son 3 gün" gibi kayan bir pencerede sınır her + * saniye kaydığı için hiçbir şey önbelleklenemezdi. + * + * Saat dilimi UTC, çünkü retention (db/retention.sql) da UTC'ye göre siliyor. + * İki ayrı "gün" tanımı olsaydı, kapandı sanılan bir bloğun içi retention + * gece yarısı çalıştığında değişebilirdi. + * + * Blok kullanıcıya HİÇ gösterilmez (§9.5). Arayüzde yalnızca "son X" aralıkları + * ve satır bazında yerel saat var; blok tamamen içeride, çekme birimi olarak yaşar. + */ +import { supabase } from "./supabase"; + +export type LogLevel = "info" | "warning" | "error" | "critical"; + +export type LogRow = { + id: string; + measured_at: string; + level: LogLevel; + message: string; + source: string | null; +}; + +/* --- Kullanıcıya gösterilen aralıklar (§9.5) ------------------------------ */ + +export type RangeKey = "24h" | "2d" | "5d" | "7d" | "10d"; + +export const RANGES: { key: RangeKey; label: string; seconds: number }[] = [ + { key: "24h", label: "24 saat", seconds: 24 * 3600 }, + { key: "2d", label: "2 gün", seconds: 2 * 86400 }, + { key: "5d", label: "5 gün", seconds: 5 * 86400 }, + { key: "7d", label: "1 hafta", seconds: 7 * 86400 }, + { key: "10d", label: "10 gün", seconds: 10 * 86400 }, +]; + +/* --- Seviye süzgeci ------------------------------------------------------- */ + +/** + * Süzgeç bir EŞİKTİR, tam eşleşme değil: "warning" seçildiğinde error ve + * critical de listede kalır. Aksi hâlde "warning" filtresi bir error'ı + * gizlerdi — kullanıcı sorunları arıyorken en ciddi satırı saklamak, + * süzgecin var oluş amacına ters. + */ +export type LevelFilter = "all" | "warning" | "error"; + +export const LEVELS_AT_OR_ABOVE: Record = { + all: ["info", "warning", "error", "critical"], + warning: ["warning", "error", "critical"], + error: ["error", "critical"], +}; + +export const LEVEL_FILTER_LABEL: Record = { + all: "hepsi", + warning: "warning+", + error: "error+", +}; + +/* --- Blok aritmetiği ------------------------------------------------------ */ + +const DAY_MS = 86_400_000; + +/** Bir ana karşılık gelen UTC gün anahtarı: "2026-08-28". */ +export function blockKey(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +export function blockStart(key: string): number { + return Date.parse(`${key}T00:00:00.000Z`); +} + +/** + * Aralığı kapsayan blok anahtarları, YENİDEN ESKİYE. + * + * "Son 24 saat" ile "bugünün bloğu" aynı şey değildir: saat 09:00'da bugünün + * bloğu 9 saatliktir, dolayısıyla dünün bloğu da gerekir. Aynı sebeple + * "son 2 gün" 3 blok indirir ve en eski bloğun kenarı kırpılır (§9.5). + */ +export function blocksForRange(now: number, seconds: number): string[] { + const start = now - seconds * 1000; + const keys: string[] = []; + let day = blockStart(blockKey(now)); + while (day + DAY_MS > start) { + keys.push(blockKey(day)); + day -= DAY_MS; + } + return keys; +} + +/* --- Çekme ---------------------------------------------------------------- */ + +/** + * Blok CACHE birimi, çekme birimi DEĞİL. Yoğun bir gün on binlerce satır + * tutabilir; bloğun tamamını tek istekte indirmek tarayıcıyı kilitlerdi. + * Bu yüzden blok içinde de sayfa sayfa ilerlenir. + */ +export const LOG_PAGE_SIZE = 200; + +export async function fetchLogPage(params: { + deviceId: string; + block: string; + level: LevelFilter; + offset: number; + rangeStartMs: number; +}): Promise<{ rows: LogRow[]; blockDone: boolean }> { + const { deviceId, block, level, offset, rangeStartMs } = params; + + // Alt sınır: bloğun başı ile aralığın başından HANGİSİ SONRAYSA o. En eski + // bloğun kenarını kırpan yer burası. + const from = Math.max(blockStart(block), rangeStartMs); + const to = blockStart(block) + DAY_MS; + + const { data, error } = await supabase() + .from("logs") + .select("id, measured_at, level, message, source") + .eq("device_id", deviceId) + .in("level", LEVELS_AT_OR_ABOVE[level]) + .gte("measured_at", new Date(from).toISOString()) + .lt("measured_at", new Date(to).toISOString()) + .order("measured_at", { ascending: false }) + .range(offset, offset + LOG_PAGE_SIZE - 1); + + if (error) throw error; + + const rows = (data ?? []) as LogRow[]; + return { rows, blockDone: rows.length < LOG_PAGE_SIZE }; +} diff --git a/dashboard/lib/time.ts b/dashboard/lib/time.ts index abb15db..43795cc 100644 --- a/dashboard/lib/time.ts +++ b/dashboard/lib/time.ts @@ -26,3 +26,31 @@ export function gb(mb: number | null | undefined): string { if (mb == null) return "—"; return `${(mb / 1024).toFixed(1)} GB`; } + +/** + * Log satırının zaman damgası — YEREL saatle (§9.5). + * + * Bloklar UTC'ye göre bölünüyor ama kullanıcı UTC'de yaşamıyor. Blok sınırı + * içeride bir önbellek detayı; ekranda görünen her saat kullanıcının kendi + * saatidir. Aynı gün içindeyse yalnızca saat, değilse gün de eklenir — + * "14:32:07" iki gün önceye aitken tek başına yanıltıcı olurdu. + */ +export function logTime(iso: string, now: number): string { + const d = new Date(iso); + const clock = d.toLocaleTimeString("tr-TR", { hour12: false }); + const sameDay = d.toDateString() === new Date(now).toDateString(); + if (sameDay) return clock; + return `${d.toLocaleDateString("tr-TR", { day: "2-digit", month: "2-digit" })} ${clock}`; +} + +/** "2026-08-28T09:00:00Z" → "28.08.2026 12:00" (yerel). Künyedeki açılış anı. */ +export function localDateTime(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleString("tr-TR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} From 80b5b613d1375a86ce9fa62f5af5f6ec90c637b4 Mon Sep 17 00:00:00 2001 From: denis Date: Sun, 30 Aug 2026 16:18:43 +0300 Subject: [PATCH 04/13] =?UTF-8?q?feat(m9):=20metrik=20seyreltme=20fonksiyo?= =?UTF-8?q?nu=20=E2=80=94=20migration=200004=20(=C2=A79.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent 5 saniyede bir ölçtüğü için tek cihazda 10 günlük veri yaklaşık 173.000 satıra çıkıyor; grafik ise 1200 piksel genişliğinde, yani satırların neredeyse tamamı hiç çizilemiyor ve ham hâlleriyle indirilmeleri çizilmeyecek veriyi ağdan geçirmek olurdu. Bu yüzden seyreltme veritabanına taşındı: yeni metrics_buckets fonksiyonu verilen aralığı en çok 5000 (varsayılan 1000) eşit kovaya bölüp her kovadan min, max ve ortalama döndürüyor, böylece 1 saatlik görünüm de 10 günlük görünüm de aynı boyutta veri indiriyor. Ortalama tek başına dönmüyor: 15 dakikalık bir kovanın ortalaması 5 saniyelik bir sıçramayı yutar ve o sıçramayı gösterebilmek projenin varlık sebebi, bu yüzden grafikte bant min-max, içinden geçen çizgi ortalama olacak (bölüm 9.6 madde 6). Yalnızca max döndürmek de bir tür yalan olurdu, makineyi olduğundan yoğun gösterirdi. Fonksiyon security invoker olarak tanımlandı — device_id bir parametre, yani değerini kullanıcı yazıyor; definer olsaydı RLS atlanır ve giriş yapmış herkes rastgele bir cihaz kimliğiyle başkasının metriklerini okuyabilirdi — ve search_path boş sabitlendiği için fonksiyon içindeki her ad tam nitelikli yazıldı. Yetki tarafında bir düzeltme yapıldı: canlı doğrulamada anon rolünün execute yetkisinin sürdüğü görüldü, çünkü Supabase public şemasındaki yeni fonksiyonlara anon ve authenticated için doğrudan grant düşürüyor ve yalnızca PUBLIC üzerinden revoke etmek o doğrudan grantı kaldırmıyor; revoke satırı rolleri tek tek sayacak biçimde güncellendi, ayrıca doğrulama sorgusuna ham yetki listesini gösteren proacl sütunu eklendi çünkü has_function_privilege yalnızca evet hayır diyor, yetkinin nereden geldiğini söylemiyordu. Boş kova bilerek dönmüyor; cihazın kapalı olduğu aralığı arayüz iki komşu kovanın zaman farkından çıkarır. Migration kuralı gereği aynı değişiklik schema.sql ve rls.sql dosyalarına da işlendi, migration tablosuna eksik kalan 0003 satırı ile 0004 satırı eklendi. Fonksiyon canlı bir veritabanı istediği için birim testinde çalıştırılamıyor; onun yerine 59 statik sözleşme testi yazıldı ve her biri, koruduğu satır bilerek bozulduğunda kırmızıya döndüğü doğrulanarak eklendi. Bu testlerin varlık sebebi şu: buradaki bozulmaların hepsi sessiz, grafik yine çiziliyor, hiçbir yerde hata üretilmiyor. --- db/migrations/0004_metrics_buckets.sql | 169 ++++++++++++ db/migrations/README.md | 7 +- db/rls.sql | 38 ++- db/schema.sql | 99 ++++++- tests/test_metrics_buckets_sql.py | 361 +++++++++++++++++++++++++ 5 files changed, 671 insertions(+), 3 deletions(-) create mode 100644 db/migrations/0004_metrics_buckets.sql create mode 100644 tests/test_metrics_buckets_sql.py diff --git a/db/migrations/0004_metrics_buckets.sql b/db/migrations/0004_metrics_buckets.sql new file mode 100644 index 0000000..9aaca27 --- /dev/null +++ b/db/migrations/0004_metrics_buckets.sql @@ -0,0 +1,169 @@ +-- ============================================================================= +-- TraceBox — db/migrations/0004_metrics_buckets.sql +-- +-- NE: `public.metrics_buckets(...)` okuma fonksiyonu eklenir. Verilen cihazın +-- verilen zaman aralığını eşit genişlikte kovalara böler ve her kovadan +-- min / max / ortalama döndürür. Hiçbir tablo, sütun veya satır değişmez. +-- +-- NEDEN: Agent 5 saniyede bir ölçüyor — günde 17.280, 10 günde ~173.000 satır +-- (tek cihaz). Grafik ise ~1200 piksel geniş; noktaların %99'u zaten +-- çizilemez. Ham satırları tarayıcıya indirmek, çizilmeyecek veriyi ağdan +-- geçirmek olurdu. Seyreltme veritabanında yapılır: hangi aralığa bakılırsa +-- bakılsın ekrana ~1000 nokta iner, 1 saatlik görünüm de 10 günlük görünüm +-- de aynı hızda açılır (CLAUDE.md §9.7). +-- +-- min ve max, ortalamayla BİRLİKTE döner. Ortalama tek başına yalan söyler: +-- 5 saniyelik bir CPU patlaması 15 dakikalık bir kovanın ortalamasında +-- kaybolur, oysa o patlama ürünün varlık sebebi. Grafikte bant min-max, +-- içinden geçen çizgi ortalamadır (§9.6 madde 6). Yalnızca max da bir tür +-- yalandır — makineyi olduğundan yoğun gösterir. +-- +-- GÜVENLİK — SECURITY INVOKER, bu dosyanın en kritik satırı: +-- Fonksiyon ÇAĞIRANIN yetkisiyle koşar, dolayısıyla `metrics` üzerindeki +-- RLS politikası (account_id = auth.uid()) uygulanmaya devam eder. +-- Başkasının device_id değeri parametre olarak geçilirse SIFIR satır döner. +-- +-- SECURITY DEFINER olsaydı fonksiyon SAHİBİNİN yetkisiyle koşar ve RLS +-- atlanırdı: giriş yapmış herhangi bir kullanıcı, rastgele bir device_id +-- yazarak başkasının metriklerini okuyabilirdi. Parametreyi kullanıcı +-- verdiği için bu teorik bir risk değil, doğrudan bir okuma kapısı olurdu. +-- +-- Projede bilerek DEFINER olan bir fonksiyon var: triggers.sql içindeki +-- handle_new_user. Onun accounts tablosuna yazabilmesinin başka yolu yok — +-- tetiklendiği anda ortada henüz bir oturum yok. İki fonksiyon, iki zıt +-- karar; ikisinin de gerekçesi dosyasında yazılı. +-- +-- `set search_path = ''` aynı savunmanın ikinci katmanı: fonksiyon +-- içindeki her ad tam nitelikli (public.metrics) yazılır, böylece çağıranın +-- search_path ayarı fonksiyonun hangi tabloyu okuduğunu değiştiremez. +-- +-- BOŞ KOVA DÖNMEZ: cihazın kapalı olduğu bir aralıkta o kovalar sonuç kümesinde +-- hiç görünmez. Bilgi kaybı değil — arayüz iki komşu kovanın zaman farkına +-- bakıp boşluğu kendisi çizer. Boş kovaları da döndürmek, üç saatlik verisi +-- olan bir cihaz için 1000 satırın 900'ünü boşuna taşımak olurdu. +-- +-- İNDEKS: yeni indeks GEREKMEZ. Sorgu (device_id, measured_at) üzerinden yürür; +-- o indeks schema.sql'de metrics tablosuyla birlikte zaten oluşturuluyor. +-- +-- TARİH: 2026-08-29 — M9 (Dashboard), zaman çizelgesi dilimi. +-- 2026-08-30 — düzeltme: revoke yalnızca PUBLIC'ten yapılıyordu, +-- anon'un Supabase default privileges üzerinden gelen DOĞRUDAN +-- grant'ı yerinde kalmıştı (canlı doğrulama anon_execute = true +-- döndü). Dosya baştan sona yeniden çalıştırılabilir. +-- ============================================================================= + +create or replace function public.metrics_buckets( + p_device_id uuid, + p_from timestamptz, + p_to timestamptz, + p_buckets int default 1000 +) +returns table ( + bucket_start timestamptz, + samples int, + cpu_min real, + cpu_max real, + cpu_avg real, + ram_min int, + ram_max int, + ram_avg real, + disk_min real, + disk_max real, + disk_avg real +) +language sql +stable +security invoker +set search_path = '' +as $$ + with cfg as ( + select + p_from as t0, + -- Kova sayısı sınırlanır. 1000 ekranın çözünürlüğüne oranlı sayıdır ama + -- parametre dışarıdan geliyor; sınırsız bırakılsaydı tek bir istek + -- milyonlarca kova üretip veritabanını meşgul edebilirdi. + least(greatest(coalesce(p_buckets, 1000), 1), 5000)::int as n, + -- Aralık en az 1 saniye sayılır: p_from = p_to gelirse kova genişliği + -- sıfır olur ve sorgu sıfıra bölme hatasıyla düşerdi. + greatest(extract(epoch from (p_to - p_from)), 1) as span_s + ), + w as ( + select t0, n, span_s, span_s / n as width_s from cfg + ) + select + -- Satırın kovası: aralığın başından bu yana geçen saniye kova genişliğine + -- bölünür, aşağı yuvarlanır, tekrar saniyeye çevrilip başlangıca eklenir. + -- Dönen değer kovanın BAŞLANGIÇ anıdır. + w.t0 + make_interval( + secs => (floor(extract(epoch from (m.measured_at - w.t0)) / w.width_s) * w.width_s)::double precision + ) as bucket_start, + count(*)::int as samples, + min(m.cpu_percent) as cpu_min, + max(m.cpu_percent) as cpu_max, + avg(m.cpu_percent)::real as cpu_avg, + min(m.ram_used_mb) as ram_min, + max(m.ram_used_mb) as ram_max, + avg(m.ram_used_mb)::real as ram_avg, + min(m.disk_percent) as disk_min, + max(m.disk_percent) as disk_max, + avg(m.disk_percent)::real as disk_avg + from public.metrics m + cross join w + where m.device_id = p_device_id + and m.measured_at >= p_from + and m.measured_at < p_to + group by 1 + order by 1; +$$; + +-- Yetki İKİ ayrı yerden geliyor; ikisini birden kapatmak gerekiyor: +-- +-- 1. Postgres yeni bir fonksiyona EXECUTE'u varsayılan olarak PUBLIC'e verir. +-- 2. Supabase, public şemasındaki yeni fonksiyonlar için `alter default +-- privileges ... grant all on functions to anon, authenticated, +-- service_role` tanımlıdır — yani anon'a AYRICA, DOĞRUDAN bir grant düşer. +-- +-- Yalnızca PUBLIC'ten almak yetmez: 2. maddedeki doğrudan grant yerinde kalır. +-- 2026-08-30 canlı doğrulamasında tam olarak bu oldu — `from public` sonrası +-- anon_execute hâlâ true döndü. Bu yüzden anon açıkça yazılır; dosyanın geri +-- kalanındaki iki revoke da (devices, commands) aynı sebeple rolleri sayar. +-- +-- RLS zaten sıfır satır döndürürdü, ama kapatılabilecek bir kapıyı açık +-- bırakmanın karşılığı yok. service_role bilerek dokunulmadan bırakıldı: +-- collector zaten RLS'i bypass ediyor. +-- İki satır da tekrar çalıştırılabilir: aynı sonucu üretir, hata vermez. +revoke all on function public.metrics_buckets(uuid, timestamptz, timestamptz, int) from public, anon; +grant execute on function public.metrics_buckets(uuid, timestamptz, timestamptz, int) to authenticated; + +-- ============================================================================= +-- DOĞRULAMA — TEK satır dönmeli ve sütunlar şu değerleri taşımalı: +-- security_definer = false (true ise RLS atlanıyor — KRİTİK) +-- settings = ["search_path=\"\""] (boş search_path'in yazılışı) +-- authenticated_execute = true +-- anon_execute = false +-- grants = içinde anon=X/... SATIRI OLMAMALI +-- +-- settings neden `search_path=""` görünüyor: Postgres GUC değerlerini proconfig +-- içinde tırnaklayarak saklar; boş dize `""` olarak yazılır. Beklenen budur. +-- +-- grants sütunu proacl'ı ham haliyle gösterir. anon_execute tek başına true/false +-- der ama NEREDEN geldiğini söylemez; yetkinin doğrudan bir grant'tan mı yoksa +-- PUBLIC'ten mi devraldığını yalnızca bu liste gösterir. İlk çalıştırmada +-- anon_execute'un true dönmesinin sebebi buradan okunabilirdi. +-- ============================================================================= +select p.proname, + p.prosecdef as security_definer, + p.proconfig as settings, + has_function_privilege('authenticated', p.oid, 'execute') as authenticated_execute, + has_function_privilege('anon', p.oid, 'execute') as anon_execute, + p.proacl as grants + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' + and p.proname = 'metrics_buckets'; + +-- ----------------------------------------------------------------------------- +-- GERİ ALMA (çalıştırılmaz — sadece kayıt): +-- drop function if exists +-- public.metrics_buckets(uuid, timestamptz, timestamptz, int); +-- ----------------------------------------------------------------------------- diff --git a/db/migrations/README.md b/db/migrations/README.md index 025fe65..7074d7c 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -46,7 +46,12 @@ Bedeli, birini güncelleyip diğerini unutma riskidir — bu yüzden kural burad |---|-------|------|---------| | 0001 | `0001_received_at.sql` | `received_at` sütunu + indeksler (bulgu B5) | ✅ 2026-08-21 | | 0002 | `0002_commands_insert_grant.sql` | `commands` INSERT yetkisi daraltıldı (bulgu B7) | ✅ 2026-08-21 | +| 0003 | `0003_drop_pending_delete.sql` | `devices.pending_delete` sütunu kaldırıldı | ✅ 2026-08-26 | +| 0004 | `0004_metrics_buckets.sql` | `metrics_buckets()` grafik seyreltme fonksiyonu (§9.7) | ✅ 2026-08-30 | -> Her ikisi de canlı Supabase'de **elle** çalıştırıldı; dosyalar sonradan kayda geçirildi. +> Hepsi canlı Supabase'de **elle** çalıştırılır; dosyalar önce yazılır, sonra uygulanır. +> "Canlıda" sütunu ✅ olmayan bir migration henüz veritabanına girmemiştir — koddan onun +> varlığına güvenilemez. +> > Yeni bir ortam kurulursa: `schema.sql` → `triggers.sql` → `rls.sql`, migration'lara gerek yok > (değişiklikler zaten o dosyalara işlendi). Migration'lar **mevcut** bir veritabanı içindir. diff --git a/db/rls.sql b/db/rls.sql index 0aa2e98..4786c75 100644 --- a/db/rls.sql +++ b/db/rls.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- TraceBox — db/rls.sql --- Row-Level Security politikaları + kolon bazlı yetkiler. +-- Row-Level Security politikaları + kolon bazlı yetkiler + fonksiyon yetkileri. -- -- ÇALIŞTIRMA SIRASI: schema.sql -> triggers.sql -> [rls.sql] -- @@ -200,10 +200,46 @@ grant insert (device_id, account_id, type) on public.commands to authenticated; -- silinmesiyle CASCADE üzerinden olur. +-- ----------------------------------------------------------------------------- +-- 6) metrics_buckets — FONKSİYON ÇALIŞTIRMA yetkisi +-- ----------------------------------------------------------------------------- +-- schema.sql'deki grafik seyreltme fonksiyonu (§9.7). Tabloya değil, fonksiyona +-- verilen bir yetki — o yüzden RLS politikası değil, grant konusu. +-- +-- Yetki İKİ ayrı yerden geliyor; ikisini birden kapatmak gerekiyor: +-- +-- 1. Postgres yeni bir fonksiyona EXECUTE'u varsayılan olarak PUBLIC'e verir. +-- 2. Supabase, public şemasındaki yeni fonksiyonlar için `alter default +-- privileges ... grant all on functions to anon, authenticated, +-- service_role` tanımlıdır — yani anon'a AYRICA, DOĞRUDAN bir grant düşer. +-- +-- Yalnızca PUBLIC'ten almak yetmez: 2. maddedeki doğrudan grant yerinde kalır. +-- 2026-08-30 canlı doğrulamasında tam olarak bu oldu — `from public` sonrası +-- anon_execute hâlâ true döndü. Bu yüzden anon açıkça yazılır; yukarıdaki iki +-- revoke (3b devices, 5b commands) da aynı sebeple rolleri tek tek sayıyor. +-- +-- Fonksiyon SECURITY INVOKER olduğu için anon çağırsa bile RLS devrede kalır ve +-- sıfır satır döner — ama kapatılabilecek bir kapıyı açık bırakmanın karşılığı +-- yok. service_role bilerek dokunulmadan bırakıldı: collector zaten RLS'i +-- bypass ediyor. +-- +-- SECURITY INVOKER'ın kendisi schema.sql'de, fonksiyon tanımının içinde durur ve +-- bu dosyadaki her politikadan daha kritiktir: DEFINER'a dönerse buradaki tüm +-- select politikaları o fonksiyon üzerinden atlanır. Gerekçesi fonksiyonun +-- başındaki yorumda. +-- +-- İki satır da tekrar çalıştırılabilir: aynı sonucu üretir, hata vermez. +revoke all on function public.metrics_buckets(uuid, timestamptz, timestamptz, int) from public, anon; +grant execute on function public.metrics_buckets(uuid, timestamptz, timestamptz, int) to authenticated; + + -- ----------------------------------------------------------------------------- -- DOĞRULAMA (Supabase Dashboard): -- 1. Table Editor -> 6 tablonun da yanında yeşil "RLS enabled" rozeti olmalı. -- 2. Advisors -> Security -> "RLS disabled in public" uyarısı KALMAMALI. -- 3. Gerçek test (M9, dashboard hazır olunca): iki test kullanıcısı aç, -- birinin oturumuyla diğerinin cihazlarını sorgula -> 0 satır dönmeli. +-- 4. metrics_buckets fonksiyonu: prosecdef = false (SECURITY INVOKER) ve +-- anon'un execute yetkisi olmamalı. Sorgusu 0004_metrics_buckets.sql +-- dosyasının sonunda duruyor. -- ----------------------------------------------------------------------------- diff --git a/db/schema.sql b/db/schema.sql index 35c45c2..da88603 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,6 @@ -- ============================================================================= -- TraceBox — db/schema.sql --- 6 tablo, indeksler ve foreign key'ler. +-- 6 tablo, indeksler, foreign key'ler ve grafik seyreltme fonksiyonu. -- -- Bu dosya SIFIRDAN kurulum içindir: tabloların ŞU ANKİ halini anlatır ve boş bir -- veritabanına çalıştırılır. Zaten kurulmuş bir veritabanını güncellemek için @@ -272,3 +272,100 @@ create table commands ( -- Agent'ın komut poll'unda attığı sorgunun şekli: bu cihazın bekleyen komutları. create index commands_device_status_idx on commands (device_id, status); + + +-- ============================================================================= +-- 7) metrics_buckets — grafik için seyreltme (downsampling) fonksiyonu +-- ============================================================================= +-- Tablo değil, OKUMA fonksiyonu. Verilen cihazın verilen zaman aralığını eşit +-- genişlikte kovalara böler ve her kovadan min / max / ortalama döndürür. +-- +-- Neden gerekli: agent 5 saniyede bir ölçüyor — günde 17.280, 10 günde ~173.000 +-- satır (tek cihaz). Grafik ~1200 piksel geniş; noktaların %99'u zaten +-- çizilemez. Ham satırları tarayıcıya indirmek, çizilmeyecek veriyi ağdan +-- geçirmek olurdu. Seyreltme burada, veritabanında yapılır: hangi aralığa +-- bakılırsa bakılsın ekrana ~1000 nokta iner (CLAUDE.md §9.7). +-- +-- Neden min/max da dönüyor: ortalama tek başına yalan söyler. 5 saniyelik bir +-- CPU patlaması 15 dakikalık bir kovanın ortalamasında kaybolur — oysa o patlama +-- ürünün varlık sebebi. Grafikte bant min-max, içinden geçen çizgi ortalamadır +-- (§9.6 madde 6). Yalnızca max da bir tür yalandır: makineyi olduğundan yoğun +-- gösterir. +-- +-- SECURITY INVOKER (kritik): fonksiyon ÇAĞIRANIN yetkisiyle koşar, dolayısıyla +-- metrics üzerindeki RLS politikası uygulanmaya devam eder — başkasının +-- device_id değeri geçilirse sıfır satır döner. DEFINER olsaydı RLS atlanır ve +-- device_id kullanıcıdan geldiği için doğrudan bir okuma kapısı açılırdı. +-- (triggers.sql'deki handle_new_user bilerek DEFINER'dır; gerekçesi orada.) +-- set search_path = '' aynı savunmanın ikinci katmanı: fonksiyon içindeki her ad +-- tam nitelikli yazılır, çağıranın search_path'i hangi tablonun okunduğunu +-- değiştiremez. +-- +-- Boş kova dönmez: cihazın kapalı olduğu aralıkta o kovalar sonuç kümesinde hiç +-- görünmez. Arayüz iki komşu kovanın zaman farkına bakıp boşluğu kendi çizer. +-- +-- Yeni indeks gerekmez: sorgu yukarıdaki (device_id, measured_at) indeksi +-- üzerinden yürür. +create or replace function public.metrics_buckets( + p_device_id uuid, + p_from timestamptz, + p_to timestamptz, + p_buckets int default 1000 +) +returns table ( + bucket_start timestamptz, + samples int, + cpu_min real, + cpu_max real, + cpu_avg real, + ram_min int, + ram_max int, + ram_avg real, + disk_min real, + disk_max real, + disk_avg real +) +language sql +stable +security invoker +set search_path = '' +as $$ + with cfg as ( + select + p_from as t0, + -- Kova sayısı sınırlanır. 1000 ekranın çözünürlüğüne oranlı sayıdır ama + -- parametre dışarıdan geliyor; sınırsız bırakılsaydı tek bir istek + -- milyonlarca kova üretip veritabanını meşgul edebilirdi. + least(greatest(coalesce(p_buckets, 1000), 1), 5000)::int as n, + -- Aralık en az 1 saniye sayılır: p_from = p_to gelirse kova genişliği + -- sıfır olur ve sorgu sıfıra bölme hatasıyla düşerdi. + greatest(extract(epoch from (p_to - p_from)), 1) as span_s + ), + w as ( + select t0, n, span_s, span_s / n as width_s from cfg + ) + select + -- Satırın kovası: aralığın başından bu yana geçen saniye kova genişliğine + -- bölünür, aşağı yuvarlanır, tekrar saniyeye çevrilip başlangıca eklenir. + -- Dönen değer kovanın BAŞLANGIÇ anıdır. + w.t0 + make_interval( + secs => (floor(extract(epoch from (m.measured_at - w.t0)) / w.width_s) * w.width_s)::double precision + ) as bucket_start, + count(*)::int as samples, + min(m.cpu_percent) as cpu_min, + max(m.cpu_percent) as cpu_max, + avg(m.cpu_percent)::real as cpu_avg, + min(m.ram_used_mb) as ram_min, + max(m.ram_used_mb) as ram_max, + avg(m.ram_used_mb)::real as ram_avg, + min(m.disk_percent) as disk_min, + max(m.disk_percent) as disk_max, + avg(m.disk_percent)::real as disk_avg + from public.metrics m + cross join w + where m.device_id = p_device_id + and m.measured_at >= p_from + and m.measured_at < p_to + group by 1 + order by 1; +$$; diff --git a/tests/test_metrics_buckets_sql.py b/tests/test_metrics_buckets_sql.py new file mode 100644 index 0000000..a1f0718 --- /dev/null +++ b/tests/test_metrics_buckets_sql.py @@ -0,0 +1,361 @@ +""" +db/migrations/0004_metrics_buckets.sql — sözleşme testleri. + +Fonksiyon burada ÇALIŞTIRILAMAZ: canlı bir Postgres ve içinde veri ister; test +ortamında ikisi de yok. Gerçekten doğru sonuç ürettiği Supabase'de, dosyanın +sonundaki doğrulama sorgusuyla kontrol edilir. + +Buradaki testler farklı bir soruya bakar: dosya SÖZLEŞMESİNİ hâlâ tutuyor mu? + +Bu fonksiyonun bozulma biçimlerinin hepsi sessizdir — grafik yine çizilir: + * `security invoker` -> `security definer`'a kayarsa RLS atlanır ve giriş + yapmış herhangi bir kullanıcı, rastgele bir device_id yazarak BAŞKASININ + metriklerini okur. Ekranda hiçbir fark görünmez. + * min/max düşer, yalnızca ortalama kalırsa 5 saniyelik CPU patlaması kovanın + ortalamasında kaybolur. Grafik "her şey yolunda" der — oysa o patlamayı + göstermek ürünün varlık sebebi. + * aralık yarı açık olmaktan çıkarsa (`<` yerine `<=`) sınırdaki ölçüm iki + komşu blokta birden sayılır. + * kova sayısının sınırı düşerse tek bir istek milyonlarca kova üretir. + * `search_path` sabitlenmezse fonksiyonun hangi tabloyu okuduğunu çağıran + belirleyebilir. + +Beşinde de hata çıkmaz. Fark eden başka hiçbir şey yok. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +DB = Path(__file__).resolve().parent.parent / "db" + +MIGRATION = DB / "migrations" / "0004_metrics_buckets.sql" +SCHEMA = DB / "schema.sql" +RLS = DB / "rls.sql" + +FUNC = "metrics_buckets" + +# Grafikte çizilen üç ölçü. Her biri için üçlü (min, max, avg) birden dönmeli. +MEASURES = ["cpu_percent", "ram_used_mb", "disk_percent"] +AGGREGATES = ["min", "max", "avg"] + +# revoke/grant satırlarının hedeflediği imza. Fonksiyona bir parametre eklenip +# bu liste güncellenmezse yetki satırları var olmayan bir overload'a bakar. +SIGNATURE = ["uuid", "timestamptz", "timestamptz", "int"] + +# EXECUTE yetkisinin alınması gereken roller. service_role listede DEĞİL: +# collector zaten RLS'i bypass ediyor, ondan yetki almanın bir karşılığı yok. +REVOKED_ROLES = ["public", "anon"] + + +def code(body: str) -> str: + """SQL yorumlarını atar. + + Yorumlar bu dosyada iddiaların hepsini kelime kelime anlatıyor — süzgeç + olmadan `security invoker` koddan silinse bile testler yeşil kalırdı. + """ + return "\n".join(line.split("--")[0] for line in body.splitlines()) + + +def read(path: Path) -> str: + return code(path.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def migration() -> str: + return read(MIGRATION) + + +@pytest.fixture(scope="module") +def rls() -> str: + return read(RLS) + + +def definition(sql: str) -> str: + """`create or replace function ... $$ ... $$;` bloğunun tamamı.""" + match = re.search( + rf"create\s+or\s+replace\s+function\s+public\.{FUNC}\b.*?\$\$.*?\$\$\s*;", + sql, + re.DOTALL, + ) + assert match, f"{FUNC} fonksiyon tanımı bulunamadı" + return match.group(0) + + +def header(sql: str) -> str: + """Tanımın gövde ÖNCESİ kısmı: imza, dönüş tipi ve fonksiyon nitelikleri.""" + return definition(sql).split("$$")[0] + + +def body(sql: str) -> str: + """`$$ ... $$` arasındaki sorgunun kendisi.""" + return definition(sql).split("$$")[1] + + +# Fonksiyon tanımı İKİ dosyada birden durur: migration (mevcut veritabanını +# değiştiren adım) ve schema.sql (sıfırdan kurulumun anlattığı son hal). +# Kural: db/migrations/README.md. Aşağıdaki tanım testleri ikisine de koşar — +# birine yazılıp diğerine yazılmayan bir değişiklik burada yakalanır. +DEFINITION_FILES = [MIGRATION, SCHEMA] + +# Yetki satırları da iki dosyada: migration ve rls.sql. +GRANT_FILES = [MIGRATION, RLS] + + +# --- Dosyalar -------------------------------------------------------------- + + +@pytest.mark.parametrize("path", [MIGRATION, SCHEMA, RLS]) +def test_file_exists(path): + """Üçü de olmalı: migration mevcut veritabanını, diğer ikisi sıfırdan kurulumu anlatır.""" + assert path.is_file() + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_function_is_defined(path): + """Tanım hem migration'da hem schema.sql'de bulunmalı (iki dosya kuralı).""" + assert definition(read(path)) + + +# --- Güvenlik: bu bölüm dosyanın en kritik kısmı --------------------------- + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_function_runs_with_the_callers_permissions(path): + """SECURITY INVOKER: fonksiyon çağıranın yetkisiyle koşar, RLS devrede kalır. + + device_id bir PARAMETRE — yani değerini kullanıcı yazar. RLS olmasa + başkasının cihaz id'sini yazan herkes onun metriklerini okurdu. + """ + assert re.search(r"security\s+invoker", header(read(path))) + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_function_is_never_security_definer(path): + """DEFINER, RLS'i atlar ve bu fonksiyonu doğrudan bir okuma kapısına çevirir. + + Ayrı bir test: `invoker` eklenmiş ama `definer` da bırakılmış bir dosyada + Postgres sonuncuyu uygular; tek bir varlık testi bunu kaçırır. + """ + assert not re.search(r"security\s+definer", header(read(path))) + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_search_path_is_pinned_to_empty(path): + """`set search_path = ''` — çağıranın arama yolu fonksiyonu yönlendiremesin. + + Sabitlenmezse çağıran, kendi şemasına `metrics` adında bir tablo koyup + fonksiyona onu okutabilir. + """ + assert re.search(r"set\s+search_path\s*=\s*''", header(read(path))) + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_tables_are_schema_qualified(path): + """search_path boşken nitelenmemiş bir ad ÇÖZÜLEMEZ; tablo `public.metrics` yazılmalı.""" + sql = body(read(path)) + assert re.search(r"from\s+public\.metrics\b", sql) + assert not re.search(r"from\s+metrics\b", sql) + + +@pytest.mark.parametrize("path", GRANT_FILES, ids=lambda p: p.name) +@pytest.mark.parametrize("role", REVOKED_ROLES) +def test_execute_is_revoked(path, role): + """EXECUTE yetkisi İKİ yerden geliyor; revoke ikisini de saymalı. + + public : Postgres'in yeni fonksiyonlara verdiği varsayılan yetki. + anon : Supabase'in default privileges ayarından gelen DOĞRUDAN grant. + + anon'u yalnızca public'ten revoke ederek kaldıramazsın — doğrudan grant + yerinde kalır. 2026-08-30 canlı doğrulamasında tam olarak bu oldu: + `from public` çalıştı, hata vermedi, anon_execute yine de true döndü. + Sessiz başarısızlık: SQL yeşil, kapı açık. + """ + match = re.search( + rf"revoke\s+all\s+on\s+function\s+public\.{FUNC}\s*\([^)]*\)\s*from\s+([^;]+);", + read(path), + ) + assert match, "revoke satırı bulunamadı" + roles = {r.strip() for r in match.group(1).split(",")} + assert role in roles + + +@pytest.mark.parametrize("path", GRANT_FILES, ids=lambda p: p.name) +def test_execute_is_granted_to_authenticated(path): + """Dashboard'ın grafiği çizebilmesi için giriş yapmış kullanıcı çağırabilmeli.""" + assert re.search( + rf"grant\s+execute\s+on\s+function\s+public\.{FUNC}\s*\([^)]*\)\s*to\s+authenticated", + read(path), + ) + + +@pytest.mark.parametrize("path", GRANT_FILES, ids=lambda p: p.name) +def test_execute_is_granted_to_nobody_else(path): + """anon veya public'e verilen bir grant, az önce alınan yetkiyi geri verir.""" + grants = re.findall( + rf"grant\s+execute\s+on\s+function\s+public\.{FUNC}\s*\([^)]*\)\s*to\s+(\w+)", + read(path), + ) + assert grants, "hiç grant bulunamadı" + assert set(grants) == {"authenticated"} + + +@pytest.mark.parametrize("path", GRANT_FILES, ids=lambda p: p.name) +def test_grant_signature_matches_the_function_parameters(path): + """Yetki satırları imzayla eşleşmeli. + + Fonksiyona parametre eklenip bu satırlar güncellenmezse revoke/grant var + olmayan bir overload'a bakar: migration hata verir ya da yetki hiç değişmez. + """ + signatures = re.findall( + rf"on\s+function\s+public\.{FUNC}\s*\(([^)]*)\)", read(path) + ) + assert signatures, "revoke/grant imzası bulunamadı" + for raw in signatures: + types = [t.strip() for t in raw.split(",")] + assert types == SIGNATURE + + +def test_declared_parameters_match_the_grant_signature(): + """Fonksiyonun KENDİ parametre tipleri de aynı imzayı vermeli. + + Yukarıdaki test grant satırlarını sabit listeye bağlar; bu test listenin + fonksiyonun gerçek haliyle aynı kaldığını doğrular. İkisi ayrı olmasa + parametre eklendiğinde her şey birlikte kayar ve hiçbir test kırılmaz. + """ + match = re.search( + rf"function\s+public\.{FUNC}\s*\((.*?)\)\s*returns", + read(MIGRATION), + re.DOTALL, + ) + assert match, "fonksiyon parametre listesi bulunamadı" + declared = [ + param.strip().split()[1] + for param in match.group(1).split(",") + if param.strip() + ] + assert declared == SIGNATURE + + +# --- Grafiğin dürüstlüğü --------------------------------------------------- + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +@pytest.mark.parametrize("measure", MEASURES) +@pytest.mark.parametrize("aggregate", AGGREGATES) +def test_every_measure_reports_min_max_and_avg(path, measure, aggregate): + """Üç ölçünün üçü de min, max VE ortalamayla dönmeli. + + Ortalama tek başına yalan söyler: 5 saniyelik bir patlama 15 dakikalık + kovanın ortalamasında kaybolur. Yalnızca max da yalandır — makineyi + olduğundan yoğun gösterir. Grafikte bant min-max, çizgi ortalamadır. + """ + assert re.search(rf"{aggregate}\s*\(\s*m\.{measure}\s*\)", body(read(path))) + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_rows_are_filtered_by_device(path): + """Cihaz süzgeci düşerse grafik hesaptaki TÜM cihazları tek çizgide toplar. + + RLS bunu engellemez: satırların hepsi çağıranın kendi hesabına aittir. + """ + assert re.search(r"m\.device_id\s*=\s*p_device_id", body(read(path))) + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_the_time_range_is_half_open(path): + """[p_from, p_to) — başlangıç dahil, bitiş hariç. + + İki uç da dahil olsaydı, ardışık iki blok çekildiğinde sınırdaki ölçüm + ikisinde birden sayılırdı (§9.5 blok blok çekme). + """ + sql = body(read(path)) + assert re.search(r"m\.measured_at\s*>=\s*p_from", sql) + assert re.search(r"m\.measured_at\s*<\s*p_to", sql) + assert not re.search(r"m\.measured_at\s*<=\s*p_to", sql) + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_bucket_count_is_clamped(path): + """p_buckets dışarıdan geliyor; sınırsız bırakılırsa tek istek veritabanını meşgul eder.""" + sql = body(read(path)) + assert re.search(r"least\s*\(", sql) + assert re.search(r"greatest\s*\(\s*coalesce\s*\(\s*p_buckets", sql) + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_the_span_cannot_be_zero(path): + """p_from = p_to gelirse kova genişliği 0 olur ve sorgu sıfıra bölmeden düşer.""" + assert re.search( + r"greatest\s*\(\s*extract\s*\(\s*epoch\s+from\s*\(\s*p_to\s*-\s*p_from\s*\)\s*\)\s*,\s*1\s*\)", + body(read(path)), + ) + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_buckets_are_grouped_and_ordered(path): + """Kovalar gruplanmazsa satır sayısı seyrelmez; sıralanmazsa grafik ileri geri zıplar.""" + sql = body(read(path)) + assert re.search(r"group\s+by", sql) + assert re.search(r"order\s+by", sql) + + +@pytest.mark.parametrize("path", DEFINITION_FILES, ids=lambda p: p.name) +def test_function_is_declared_stable(path): + """STABLE: aynı sorgu içinde aynı girdi aynı sonucu verir -> planlayıcı optimize edebilir. + + VOLATILE (varsayılan) kalsaydı Postgres her çağrıyı yeniden hesaplamak + zorunda kalır, hiçbir hata çıkmadan yavaşlardı. + """ + assert re.search(r"\bstable\b", header(read(path))) + + +# --- Migration hijyeni ----------------------------------------------------- + + +def test_verification_query_reads_the_security_setting(migration): + """Dosya sonundaki doğrulama sorgusu prosecdef'e bakmalı — false beklenir. + + `if not exists` yalnızca ADA bakar, ŞEKLE bakmaz; doğrulama sorgusu bu + boşluğu kapatan şeydir (db/migrations/README.md). + """ + assert "prosecdef" in migration + + +def test_verification_query_checks_that_anon_cannot_execute(migration): + """Yetkinin gerçekten daraldığı canlıda görülmeli, varsayılmamalı.""" + assert re.search( + r"has_function_privilege\s*\(\s*'anon'", migration.replace("\n", " ") + ) + + +def test_verification_query_shows_who_actually_holds_execute(migration): + """proacl ham yetki listesini gösterir — yetkinin NEREDEN geldiğini söyleyen tek şey. + + has_function_privilege yalnızca true/false der. 2026-08-30'da anon_execute + true döndüğünde, sebebin PUBLIC devri mi yoksa doğrudan bir grant mı olduğunu + ayırt edecek bilgi ekranda yoktu; proacl o boşluğu kapatıyor. + """ + assert "proacl" in migration + + +def test_rollback_exists_but_only_as_a_comment(): + """Geri alma bilgisi kaybolmasın ama kazayla çalışmasın. + + `drop function` ham metinde var, yorumları ayıklanmış kodda YOK. + """ + raw = MIGRATION.read_text(encoding="utf-8") + # [-\s]* : ifade iki yoruma bölünmüş -> aradaki satır sonu ve `--` atlanır. + assert re.search(rf"drop\s+function\s+if\s+exists[-\s]*public\.{FUNC}", raw) + assert "drop function" not in code(raw) + + +def test_migration_documents_what_why_and_when(): + """Başlıkta NE / NEDEN / TARİH bulunur — `create function` satırını herkes okur, + neden yazıldığının cevabı yalnızca yazanın kafasındadır.""" + raw = MIGRATION.read_text(encoding="utf-8") + for heading in ("NE:", "NEDEN:", "TARİH:"): + assert heading in raw From 7105e6d34de2307d02b3d292a48eccd5b1aa2b30 Mon Sep 17 00:00:00 2001 From: denis Date: Sun, 30 Aug 2026 17:32:02 +0300 Subject: [PATCH 05/13] =?UTF-8?q?feat(m9):=20dashboard=20g=C3=B6rsel=20dil?= =?UTF-8?q?i=20yeniden=20kuruldu=20(=C2=A79.11.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Örnek tasarım planı birebir uygulandı: yan menü, üst çubuk, koyu lacivert palet, yuvarlak kartlar, ince çerçeveler. İçerik uydurulmadı; gerçekten toplanan veriye eşlendi. - yan menü: logo, gerçek cihazlar ve canlı durum noktaları, profil bloğu - üst çubuk: arama, zaman aralığı, yenile, uyarı zili (lib/alerts.ts) - palet tek dosyada; cpu/ram/disk renkleri sözleşme oldu - ikonlar elle yazıldı, kütüphane eklenmedi - log seviyeleri renkli rozet; critical dolu zeminle ayrıldı - künye paneli donanım/sistem/agent olarak üçe bölündü - grafik dilimi (bant + çizgi, seyreltme) bu commit ile geliyor - tema değiştirici alınmadı; proje tek koyu kimliğe bağlı Ekranlar (app) rota grubuna taşındı, adresler değişmedi. --- dashboard/app/(app)/devices/DeviceCard.tsx | 123 +++++++ dashboard/app/(app)/devices/[id]/Chart.tsx | 244 ++++++++++++++ .../{ => (app)}/devices/[id]/DetailPanel.tsx | 122 ++++--- .../app/{ => (app)}/devices/[id]/LogList.tsx | 89 +++-- dashboard/app/(app)/devices/[id]/Timeline.tsx | 197 +++++++++++ .../app/{ => (app)}/devices/[id]/page.tsx | 90 ++--- dashboard/app/(app)/devices/page.tsx | 97 ++++++ dashboard/app/(app)/layout.tsx | 61 ++++ dashboard/app/devices/DeviceCard.tsx | 140 -------- dashboard/app/devices/[id]/Timeline.tsx | 51 --- dashboard/app/devices/page.tsx | 125 ------- dashboard/app/globals.css | 89 ++++- dashboard/components/Sidebar.tsx | 183 ++++++++++ dashboard/components/StatusPill.tsx | 44 +++ dashboard/components/Topbar.tsx | 196 +++++++++++ dashboard/components/icons.tsx | 207 ++++++++++++ dashboard/lib/alerts.ts | 131 ++++++++ dashboard/lib/appState.tsx | 152 +++++++++ dashboard/lib/metrics.ts | 312 ++++++++++++++++++ dashboard/lib/time.ts | 20 ++ dashboard/public/tracebox-logo.png | Bin 0 -> 266352 bytes dashboard/public/tracebox-mark.png | Bin 0 -> 31586 bytes 22 files changed, 2225 insertions(+), 448 deletions(-) create mode 100644 dashboard/app/(app)/devices/DeviceCard.tsx create mode 100644 dashboard/app/(app)/devices/[id]/Chart.tsx rename dashboard/app/{ => (app)}/devices/[id]/DetailPanel.tsx (51%) rename dashboard/app/{ => (app)}/devices/[id]/LogList.tsx (65%) create mode 100644 dashboard/app/(app)/devices/[id]/Timeline.tsx rename dashboard/app/{ => (app)}/devices/[id]/page.tsx (52%) create mode 100644 dashboard/app/(app)/devices/page.tsx create mode 100644 dashboard/app/(app)/layout.tsx delete mode 100644 dashboard/app/devices/DeviceCard.tsx delete mode 100644 dashboard/app/devices/[id]/Timeline.tsx delete mode 100644 dashboard/app/devices/page.tsx create mode 100644 dashboard/components/Sidebar.tsx create mode 100644 dashboard/components/StatusPill.tsx create mode 100644 dashboard/components/Topbar.tsx create mode 100644 dashboard/components/icons.tsx create mode 100644 dashboard/lib/alerts.ts create mode 100644 dashboard/lib/appState.tsx create mode 100644 dashboard/lib/metrics.ts create mode 100644 dashboard/public/tracebox-logo.png create mode 100644 dashboard/public/tracebox-mark.png diff --git a/dashboard/app/(app)/devices/DeviceCard.tsx b/dashboard/app/(app)/devices/DeviceCard.tsx new file mode 100644 index 0000000..f66c60e --- /dev/null +++ b/dashboard/app/(app)/devices/DeviceCard.tsx @@ -0,0 +1,123 @@ +/** + * 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 { FLUSH_THRESHOLD, SERIES, type SeriesDef } from "@/lib/metrics"; +import { relativeTime } from "@/lib/time"; +import { NEAR_THRESHOLD_MARGIN } from "@/lib/alerts"; +import { StatusPill } from "@/components/StatusPill"; +import { IconChevron, IconClock, IconServer } from "@/components/icons"; + +/** + * Çubuğun rengi normalde ÖLÇÜNÜN rengi (CPU indigo, RAM mor, Disk cyan) — + * plan §2'nin "renk metric ayrımı yapar" maddesi. 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. + * + * Eşikler agent'ın ACİL GÖNDERİM eşikleri (agent/config.example.toml: cpu 90, + * ram 90, disk 95). Kırmızı çubuk keyfi bir tasarım kararı değil, tam olarak + * şunu söylüyor: "agent bu değeri acil sayıp spool'u beklemeden flush ederdi". + */ +function tone(series: SeriesDef, percent: number | null) { + if (percent == null) return { bar: "bg-line", text: "text-faint" }; + const limit = FLUSH_THRESHOLD[series.key]; + if (percent >= limit) return { bar: "bg-danger", text: "text-danger" }; + if (percent >= limit - NEAR_THRESHOLD_MARGIN) + return { bar: "bg-warn", text: "text-warn" }; + return { bar: series.tone.bar, text: "text-fg" }; +} + +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 } = tone(series, clamped); + + return ( +
+
+ {series.label} + {value} +
+
+ {clamped != null && ( +
+ )} +
+
+ ); +} + +export function DeviceCard({ device, now }: { device: Device; now: number }) { + const status = deviceStatus(device, now); + const m = device.latest; + + const subtitle = + [ + [device.os_name, device.os_version].filter(Boolean).join(" "), + device.cpu_cores_logical ? `${device.cpu_cores_logical} çekirdek` : null, + device.arch, + ] + .filter(Boolean) + .join(" · ") || "envanter bekleniyor"; + + return ( + +
+ + + +
+

+ {device.device_name} +

+

+ {subtitle} +

+
+ +
+ +
+ {SERIES.map((series) => ( + + ))} +
+ +
+ + {relativeTime(device.last_seen, now)} görüldü + +
+ + ); +} diff --git a/dashboard/app/(app)/devices/[id]/Chart.tsx b/dashboard/app/(app)/devices/[id]/Chart.tsx new file mode 100644 index 0000000..59d901f --- /dev/null +++ b/dashboard/app/(app)/devices/[id]/Chart.tsx @@ -0,0 +1,244 @@ +/** + * Tek ölçünün grafiği: bant + çizgi (CLAUDE.md §9.6). + * + * BANT min–max, İÇİNDEN GEÇEN ÇİZGİ ortalama. İkisi birlikte çizilir çünkü + * ikisi ayrı soruya cevap verir: çizgi "makine genelde ne durumdaydı", bant + * "en kötü an neydi". Yalnızca ortalama çizilseydi 15 dakikalık bir kovanın + * içindeki 5 saniyelik patlama görünmezdi — ve o patlamayı göstermek bu + * projenin varlık sebebi. Yalnızca max çizilseydi makine sürekli yanıyormuş + * gibi görünürdü. + * + * Çizim el yazımı SVG; grafik kütüphanesi eklenmedi. Gereken şey iki path, + * birkaç ızgara çizgisi ve bir imleç — bunun için ~100 KB bağımlılık taşımak + * "over-engineering yok" ilkesine ters düşerdi. Ayrıca §9.8 fare tekerleğiyle + * yakınlaştırmayı YASAKLIYOR; hazır kütüphanelerin çoğunda o davranış + * varsayılan ve kapatmak, kendi çizmekten daha çok uğraş çıkarıyor. + * + * viewBox sabit (1000×100) ve preserveAspectRatio="none": koordinat matematiği + * yüzdelere dönüşmeden basit kalıyor, SVG kabına göre esniyor. Esneme çizgi + * kalınlığını da bozardı, onu vector-effect="non-scaling-stroke" tutuyor. + * Metin bu yüzden SVG İÇİNDE yok — yatay/dikey esneme yazıyı ezerdi; eksen + * etiketleri HTML olarak dışarıda duruyor. + */ +"use client"; + +import { useMemo } from "react"; +import { + ceilingFor, + formatValue, + splitOnGaps, + thresholdFor, + toPoints, + type MetricBucket, + type Point, + type SeriesDef, +} from "@/lib/metrics"; + +/** viewBox birimleri. Piksel değil — SVG kabına göre esner. */ +const W = 1000; +const H = 100; + +export function Chart({ + series, + buckets, + ramTotalMb, + fromMs, + toMs, + widthMs, + hoverIndex, + onHoverTime, +}: { + series: SeriesDef; + buckets: MetricBucket[]; + /** RAM grafiğinin tavanı. Yüzde grafikleri bunu kullanmaz. */ + ramTotalMb: number | null; + fromMs: number; + toMs: number; + /** Bir kovanın süresi — boşluk tespiti bunu ölçüt alır. */ + widthMs: number; + hoverIndex: number | null; + onHoverTime: (t: number | null) => void; +}) { + const { segments, ceiling, threshold } = useMemo(() => { + const points = toPoints(buckets, series); + const ceil = ceilingFor(series, ramTotalMb, points); + return { + segments: splitOnGaps(points, widthMs), + ceiling: ceil, + threshold: thresholdFor(series, ceil), + }; + }, [buckets, series, ramTotalMb, widthMs]); + + const span = Math.max(1, toMs - fromMs); + const x = (t: number) => ((t - fromMs) / span) * W; + const y = (v: number) => H - (Math.min(Math.max(v, 0), ceiling) / ceiling) * H; + + const hovered = hoverIndex == null ? null : buckets[hoverIndex]; + const hoveredBand = hovered ? series.pick(hovered) : null; + + function handleMove(event: React.MouseEvent) { + const box = event.currentTarget.getBoundingClientRect(); + const fraction = (event.clientX - box.left) / box.width; + onHoverTime(fromMs + fraction * span); + } + + return ( +
+
+ {/* Lejant (plan §6). Nokta, aşağıdaki çizgiyle AYNI renk sınıfından + besleniyor; iki yerde ayrı ayrı seçilseydi biri değiştiğinde + lejant sessizce yanlış ölçüyü göstermeye başlardı. */} +
+ +
onHoverTime(null)} + > + + {/* Izgara: dörtte birler. Değer okumak için değil, göz hizası için. */} + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + + {/* Acil gönderim eşiği. Bu çizginin üstü keyfi bir "tehlike" değil: + agent orada spool'u beklemeden anında boşaltır (§7). */} + {threshold < ceiling && ( + + )} + + {segments.map((segment, i) => + segment.length === 1 ? ( + // Tek noktalık parça: bant kapalı bir alan oluşturamaz, dolgu + // görünmez. Min–max yine de kaybolmasın diye dikey çizgi. + + ) : ( + + ), + )} + + {segments.map((segment, i) => ( + + ))} + + {hovered && ( + + )} + + + + {/* + İmlecin altındaki ortalama değeri işaretleyen nokta — SVG'nin İÇİNDE + DEĞİL, üstünde duran bir HTML öğesi. + + Sebep: viewBox 1000x100 ve preserveAspectRatio="none", yani SVG kabına + göre yatayda ve dikeyde farklı oranlarda esniyor. İçeriye konan bir + daire o esnemeyle birlikte yayvan bir elipse dönerdi; + vector-effect yalnızca çizgi kalınlığını kurtarır, şekli değil. + Dışarıda yüzdeyle konumlanan bir nokta her boyutta yuvarlak kalıyor. + */} + {hovered && hoveredBand?.avg != null && ( + + )} +
+
+ ); +} + +/** Üstten max boyunca ileri, alttan min boyunca geri: kapalı bant. */ +function bandPath( + points: Point[], + x: (t: number) => number, + y: (v: number) => number, +): string { + const top = points.map((p) => `${x(p.t)},${y(p.max)}`); + const bottom = [...points].reverse().map((p) => `${x(p.t)},${y(p.min)}`); + return `M${top.join("L")}L${bottom.join("L")}Z`; +} + +function linePath( + points: Point[], + x: (t: number) => number, + y: (v: number) => number, +): string { + return `M${points.map((p) => `${x(p.t)},${y(p.avg)}`).join("L")}`; +} diff --git a/dashboard/app/devices/[id]/DetailPanel.tsx b/dashboard/app/(app)/devices/[id]/DetailPanel.tsx similarity index 51% rename from dashboard/app/devices/[id]/DetailPanel.tsx rename to dashboard/app/(app)/devices/[id]/DetailPanel.tsx index eef81a7..5382222 100644 --- a/dashboard/app/devices/[id]/DetailPanel.tsx +++ b/dashboard/app/(app)/devices/[id]/DetailPanel.tsx @@ -8,35 +8,54 @@ * 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. */ "use client"; import { deviceStatus, - STATUS_LABEL, type DeviceDetail, - type DeviceStatus, } from "@/lib/devices"; import { gb, localDateTime, relativeTime } from "@/lib/time"; - -const STATUS_DOT: Record = { - online: "bg-ok", - offline: "bg-muted", - paused: "bg-warn", - deleting: "bg-danger", -}; +import { StatusPill } from "@/components/StatusPill"; +import { + IconPause, + IconPlay, + IconServer, + IconTrash, +} from "@/components/icons"; function Row({ label, value }: { label: string; value: string }) { return ( -
+
{label} - + {value}
); } +function Group({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + export function DetailPanel({ device, now, @@ -47,28 +66,29 @@ export function DetailPanel({ const status = deviceStatus(device, now); return ( -
); } diff --git a/dashboard/app/(app)/devices/page.tsx b/dashboard/app/(app)/devices/page.tsx new file mode 100644 index 0000000..e4e0781 --- /dev/null +++ b/dashboard/app/(app)/devices/page.tsx @@ -0,0 +1,97 @@ +/** + * Ekran 2 — Cihaz listesi (CLAUDE.md §9.3). + * + * Cevapladığı tek soru: "makinelerim iyi mi?" + * İki panel bölünmesi YOK (sabit bağlam olmadığı için); kartlar ızgarada. + * + * Sayfa artık ne oturum kontrolü yapıyor ne de veri çekiyor: ikisi de kabukta + * (app/(app)/layout.tsx + lib/appState.tsx). Sidebar aynı listeyi gösterdiği ve + * toolbar aynı listeden uyarı türettiği için veri TEK yerde duruyor — üç ayrı + * sorgu olsaydı üçü birbirinden birkaç saniye farklı bir "şu an"a bakardı. + */ +"use client"; + +import { useApp } from "@/lib/appState"; +import { DeviceCard } from "./DeviceCard"; +import { IconPlus, IconSearch } from "@/components/icons"; + +export default function DevicesPage() { + const { devices, error, now, query } = useApp(); + + const needle = query.trim().toLocaleLowerCase("tr"); + const shown = devices?.filter((d) => + d.device_name.toLocaleLowerCase("tr").includes(needle), + ); + + return ( +
+
+
+

Cihazlar

+

+ Agent kurulu her makine ve son ölçümü. +

+
+ {/* + "+ Cihaz Ekle" collector'a POST /devices atacak. Collector'da CORS + middleware olmadığı için tarayıcıdan yapılan istek bugün bloklanıyor + (§9.13) — buton bu yüzden devre dışı. Çalışmayan bir butonu canlı + bırakmak, kullanıcıya sebebi görünmeyen bir hata göstermek olurdu. + */} + +
+ + {error && ( +

+ Cihazlar okunamadı: {error} +

+ )} + + {devices === null && !error && ( +
+ {/* İskelet: boş ekran yerine kartın alacağı yer baştan ayrılıyor, + böylece veri gelince yerleşim zıplamıyor. */} + {[0, 1, 2].map((i) => ( +
+ ))} +
+ )} + + {devices?.length === 0 && ( +
+

Henüz cihaz yok.

+

+ Bir cihaz eklendiğinde ve agent kurulduğunda burada görünür. +

+
+ )} + + {shown?.length === 0 && devices && devices.length > 0 && ( +
+ +

+ {query} ile eşleşen cihaz yok. +

+
+ )} + + {shown && shown.length > 0 && ( +
+ {shown.map((device) => ( + + ))} +
+ )} +
+ ); +} diff --git a/dashboard/app/(app)/layout.tsx b/dashboard/app/(app)/layout.tsx new file mode 100644 index 0000000..8bc30e0 --- /dev/null +++ b/dashboard/app/(app)/layout.tsx @@ -0,0 +1,61 @@ +/** + * Uygulama kabuğu: sol sidebar + üst toolbar + içerik alanı. + * + * Route grubu `(app)` parantez içinde olduğu için ADRESTE GÖRÜNMEZ — /devices + * yine /devices. Grubun tek işi bu kabuğu paylaşan sayfaları bir araya + * toplamak; giriş ekranı (§9.12) kabuğun dışında kalıyor, çünkü orada henüz + * gezinecek bir hesap yok. + * + * Oturum kontrolü de burada, tek yerde. Daha önce her sayfa kendi kontrolünü + * yapıyordu; yeni bir sayfa eklendiğinde kontrolü unutmak, o sayfayı sessizce + * korumasız bırakırdı. (Asıl koruma RLS'te — burası yalnızca kullanıcıyı boş + * ekranla baş başa bırakmamak için.) + */ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { AppProvider } from "@/lib/appState"; +import { useSession } from "@/lib/useSession"; +import { Sidebar } from "@/components/Sidebar"; +import { Topbar } from "@/components/Topbar"; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { status, session } = useSession(); + const router = useRouter(); + const pathname = usePathname(); + const [menuOpen, setMenuOpen] = useState(false); + + useEffect(() => { + if (status === "signedOut") router.replace("/login"); + }, [status, router]); + + // Sayfa değişince çekmece kapanır. Kapanmasaydı dar ekranda kullanıcı bir + // cihaza tıkladıktan sonra açık menünün arkasında kalırdı. + useEffect(() => setMenuOpen(false), [pathname]); + + // "loading" ile "signedOut" ayrı tutuluyor (lib/useSession.ts): ikisini + // birleştirmek her yenilemede giriş ekranına bir anlık sıçrama demek. + if (status !== "signedIn") { + return ( +
+ Yükleniyor… +
+ ); + } + + return ( + + setMenuOpen(false)} /> + {/* Sidebar `fixed`; içerik geniş ekranda onun genişliği kadar içeri alınır. */} +
+ setMenuOpen(true)} /> +
{children}
+
+
+ ); +} diff --git a/dashboard/app/devices/DeviceCard.tsx b/dashboard/app/devices/DeviceCard.tsx deleted file mode 100644 index fe68699..0000000 --- a/dashboard/app/devices/DeviceCard.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/** - * 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ı. - */ -"use client"; - -import Link from "next/link"; -import { - deviceStatus, - STATUS_LABEL, - type Device, - type DeviceStatus, -} from "@/lib/devices"; -import { gb, relativeTime } from "@/lib/time"; - -const STATUS_DOT: Record = { - online: "bg-ok", - offline: "bg-muted", - paused: "bg-warn", - deleting: "bg-danger", -}; - -/** - * Çubuk rengi eşikleri, agent'ın ACİL GÖNDERİM eşiklerinin aynısı - * (agent/config.example.toml: cpu 90, ram 90, disk 95). Böylece kırmızı çubuk - * keyfi bir tasarım kararı değil, tam olarak şu anlama gelir: "agent bu değeri - * acil sayıp anında flush ederdi". Sarı, ona yaklaşıldığını gösteren erken uyarı. - */ -function barColor(percent: number, danger: number): string { - if (percent >= danger) return "bg-danger"; - if (percent >= danger - 15) return "bg-warn"; - return "bg-muted"; -} - -function Bar({ - label, - percent, - value, - danger, -}: { - label: string; - percent: number | null; - value: string; - danger: number; -}) { - const pct = percent == null ? null : Math.min(100, Math.max(0, percent)); - - return ( -
- {label} -
- {pct != null && ( -
- )} -
- - {value} - -
- ); -} - -export function DeviceCard({ device, now }: { device: Device; now: number }) { - const status = deviceStatus(device, now); - const m = device.latest; - - const ramPercent = - m?.ram_used_mb != null && device.ram_total_mb - ? (m.ram_used_mb / device.ram_total_mb) * 100 - : null; - - return ( - -
-
-
- -

{device.device_name}

-
-

- {[ - [device.os_name, device.os_version].filter(Boolean).join(" "), - device.cpu_cores_logical - ? `${device.cpu_cores_logical} çekirdek` - : null, - ] - .filter(Boolean) - .join(" · ") || "envanter bekleniyor"} -

-
- - {STATUS_LABEL[status]} - -
- -
- - - -
- -

- {relativeTime(device.last_seen, now)} görüldü -

- - ); -} diff --git a/dashboard/app/devices/[id]/Timeline.tsx b/dashboard/app/devices/[id]/Timeline.tsx deleted file mode 100644 index 74a701f..0000000 --- a/dashboard/app/devices/[id]/Timeline.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Zaman çizelgesi paneli (CLAUDE.md §9.6–§9.8). - * - * Grafiğin kendisi HENÜZ YOK: §9.7'deki seyreltme (aralığı ~1000 kovaya bölüp - * her kovadan min/max/ortalama döndüren SQL fonksiyonu) bir migration - * gerektiriyor ve migration'ı kullanıcı uyguluyor. Grafiği o fonksiyon olmadan - * çizmek, 173.000 ham satırı tarayıcıya indirmek demekti. - * - * Aralık düğmeleri buna rağmen BUGÜN burada duruyor, çünkü aralık tek ve - * ortaktır (§9.8): grafikteki seçim log listesini de daraltır. Düğmeleri şimdi - * log paneline koyup sonra taşımak, aralığın kime ait olduğu konusunda yanlış - * bir alışkanlık kurardı. - */ -"use client"; - -import { RANGES, type RangeKey } from "@/lib/logs"; - -export function Timeline({ - range, - onRangeChange, -}: { - range: RangeKey; - onRangeChange: (key: RangeKey) => void; -}) { - return ( -
-
-

Zaman çizelgesi

- {RANGES.map((r) => ( - - ))} -
- -
-

- Grafik bir sonraki dilimde gelecek (§9.7). -
- Seyreltme fonksiyonu için bir migration gerekiyor. -

-
-
- ); -} diff --git a/dashboard/app/devices/page.tsx b/dashboard/app/devices/page.tsx deleted file mode 100644 index e34f353..0000000 --- a/dashboard/app/devices/page.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Ekran 2 — Cihaz listesi (CLAUDE.md §9.3). - * - * Cevapladığı tek soru: "makinelerim iyi mi?" - * İki panel bölünmesi YOK (sabit bağlam olmadığı için); kartlar ızgarada. - */ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { supabase } from "@/lib/supabase"; -import { useSession } from "@/lib/useSession"; -import { fetchDevices, type Device } from "@/lib/devices"; -import { DeviceCard } from "./DeviceCard"; - -/** - * Liste 10 saniyede bir yenilenir — agent'ın komut poll'u ile aynı ritim. - * Daha sık sormanın anlamı yok: last_seen zaten en fazla o kadar tazeleniyor. - * - * Burada Realtime KULLANILMIYOR. Realtime, son 24 saatin logları için - * ayrıldı (§9.9); orada gecikme ürünün vaadinin parçası. Liste ekranında ise - * "12 saniye önce" yazısının bir saniye geç güncellenmesi kimseyi ilgilendirmez, - * buna karşılık her cihaz için ayrı bir abonelik açmak gerekirdi. - */ -const REFRESH_MS = 10_000; - -export default function DevicesPage() { - const { status, session } = useSession(); - const router = useRouter(); - - const [devices, setDevices] = useState(null); - const [error, setError] = useState(null); - - // Saniyede bir tikleyen "şu an". Kartlardaki "12 saniye önce" ve çevrimdışı - // rozeti bunun üzerinden hesaplanır — veriyi yeniden çekmeye gerek kalmadan - // sayaç ilerler. - const [now, setNow] = useState(() => Date.now()); - - useEffect(() => { - if (status === "signedOut") router.replace("/login"); - }, [status, router]); - - const load = useCallback(async () => { - try { - setDevices(await fetchDevices()); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }, []); - - useEffect(() => { - if (status !== "signedIn") return; - load(); - const refresh = setInterval(load, REFRESH_MS); - const tick = setInterval(() => setNow(Date.now()), 1000); - return () => { - clearInterval(refresh); - clearInterval(tick); - }; - }, [status, load]); - - if (status !== "signedIn") { - return ( -
- Yükleniyor… -
- ); - } - - return ( -
-
-
-

Cihazlar

-

- {session.user.email} -

-
-
- - -
-
- - {error && ( -

- Cihazlar okunamadı: {error} -

- )} - - {devices === null && !error && ( -

Yükleniyor…

- )} - - {devices?.length === 0 && ( -
-

Henüz cihaz yok.

-

- Bir cihaz eklendiğinde ve agent kurulduğunda burada görünür. -

-
- )} - - {devices && devices.length > 0 && ( -
- {devices.map((device) => ( - - ))} -
- )} -
- ); -} diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index edf4aa1..fc24ef8 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -1,23 +1,56 @@ @import "tailwindcss"; /* - * Görsel dil — CLAUDE.md §9.11. - * Renkler burada BİR KEZ tanımlanır; bileşenlerde ham hex kullanılmaz. - * Sebep: landing (§9.12) ile dashboard aynı dili konuşacak. Renk her dosyaya - * elle yazılsaydı, marka tonu değiştiğinde onlarca yerde ayrı ayrı kayardı. + * Görsel dil — dashboard/example/TraceBox Dashboard Tasarım Planı.md. + * + * Renkler burada BİR KEZ tanımlanır; bileşenlerde ham hex kullanılmaz. Sebep: + * landing (§9.12) ile dashboard aynı dili konuşacak. Renk her dosyaya elle + * yazılsaydı, marka tonu değiştiğinde onlarca yerde ayrı ayrı kayardı. + * + * Planın §2 maddesi tek bir kural koyuyor: "Renkleri yalnızca dekorasyon için + * değil, DURUM ve METRİC ayrımı için kullan." Bu yüzden paletin yarısı isimden + * anlam taşıyor — --color-cpu bir mavi tonu değil, "CPU şu renkle çizilir" + * sözleşmesi. Bileşen dosyaları hangi maviyi seçeceğine karar vermez. */ @theme { - --color-bg: #0a0f1f; /* koyu lacivert zemin */ - --color-panel: #121a30; /* paneller: zeminden bir ton açık */ - --color-panel-2: #182241; /* panel içi vurgusuz yüzey (input, satır) */ - --color-line: #223052; /* ince, düşük kontrastlı çerçeve */ - --color-fg: #e6ebf5; /* normal metin */ - --color-muted: #8b97b0; /* ikincil metin */ - --color-accent: #6366f1; /* indigo — YALNIZCA aksiyon ve seçili durum */ - --color-accent-strong: #4f46e5; + /* --- yüzeyler: zemin en koyu, panel bir ton açık (plan §1) ------------- */ + --color-bg: #060a14; + --color-bg-soft: #080d1a; /* sidebar / toolbar — zeminden ayrılsın */ + --color-panel: #0e1424; /* kart ve panel gövdesi */ + --color-panel-2: #141c30; /* panel İÇİ vurgusuz yüzey: input, satır, çubuk yatağı */ + --color-line: #1e2740; /* ince ve DÜŞÜK KONTRASTLI çerçeve (plan §1) */ + + /* --- metin ------------------------------------------------------------- */ + --color-fg: #e8edf7; + --color-muted: #8794ad; + --color-faint: #5a6580; /* eksen etiketi, ızgara yazısı — muted'in altı */ + + /* --- birincil aksiyon: indigo / electric blue (plan §2) ----------------- */ + --color-accent: #4f5dfb; + --color-accent-strong: #3f4ce8; + --color-accent-soft: #1b2450; /* seçili satır zemini, ikon kutusu */ + + /* --- durum renkleri (plan §2) ------------------------------------------ */ --color-ok: #22c55e; --color-warn: #f59e0b; --color-danger: #ef4444; + --color-info: #3b82f6; + + /* + * --- metrik renkleri --------------------------------------------------- + * Plan §2: primary indigo, secondary violet, "network ve I/O metriklerinde + * cyan". Disk bir I/O ölçüsü, bu yüzden cyan ona düşüyor. + * + * Üç grafik alt alta duruyor ve aynı x eksenini paylaşıyor; renk burada + * dekorasyon değil, hangi bandın hangi ölçüye ait olduğunu söyleyen tek + * işaret. Üçü de aynı renkte olsaydı imleç bir grafikten diğerine geçerken + * kullanıcı yerini kaybederdi. + */ + --color-cpu: #6366f1; + --color-ram: #a78bfa; + --color-disk: #22d3ee; + + --radius-card: 0.875rem; /* tüm kartlarda AYNI yarıçap (plan §15) */ } html, @@ -28,4 +61,36 @@ body { body { -webkit-font-smoothing: antialiased; + /* + * Referans görseldeki derinlik: sol üstten inen çok soluk bir indigo hâle. + * Plan §1 "gereksiz gradient, glow ve dekoratif elementleri minimumda tut" + * diyor — bu yüzden tek bir radial, %6 opaklıkta ve sabit. Kaydırmayla + * hareket etmiyor, dikkat çekmiyor; yalnızca düz siyahın kartonluğunu alıyor. + */ + background-image: radial-gradient( + 900px 600px at 12% -10%, + color-mix(in oklab, var(--color-accent) 9%, transparent), + transparent 70% + ); + background-attachment: fixed; +} + +/* + * Kaydırma çubuğu: varsayılan açık gri çubuk koyu panelin üstünde parlayan bir + * çizgi gibi duruyor ve göz doğrudan oraya gidiyordu. + */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-line) transparent; +} + +/* + * Klavye ile gezinen kullanıcı nerede olduğunu görmeli. Tarayıcının varsayılan + * odak halkası koyu zeminde neredeyse görünmez; accent ile değiştiriliyor. + * :focus-visible kullanılıyor, :focus değil — fareyle tıklayana halka çıkmaz. + */ +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: 4px; } diff --git a/dashboard/components/Sidebar.tsx b/dashboard/components/Sidebar.tsx new file mode 100644 index 0000000..71bfef4 --- /dev/null +++ b/dashboard/components/Sidebar.tsx @@ -0,0 +1,183 @@ +/** + * Sol sabit navigasyon (tasarım planı §3). + * + * Üstte logo + wordmark, ortada gezinme, altta kullanıcı profili — referans + * görselin yerleşimi birebir bu. + * + * Menü listesi planınkinden FARKLI ve fark bilerek: plan Services, Containers, + * Traces, Dashboards istiyor; TraceBox ajanı bunların hiçbirini toplamıyor. + * Planın kendi §12 maddesi de "metric'leri agent'ın gerçekten topladığı + * verilerle sınırla" diyor. Tıklanınca boş sayfa açan bir menü, gezinmeyi + * kolaylaştırmaz — sadece ürünü olduğundan büyük gösterir. + * + * Sidebar'ın görsel yoğunluğu bunun yerine GERÇEK veriden geliyor: hesabın + * cihazları isim isim listeleniyor. Hem referanstaki dolu his korunuyor, hem + * de bu liste gerçekten işe yarıyor — cihaz detayındayken başka bir cihaza + * geçmek için listeye dönmek gerekmiyor. + */ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { supabase } from "@/lib/supabase"; +import { useApp } from "@/lib/appState"; +import { deviceStatus } from "@/lib/devices"; +import { IconClose, IconLogout, IconServer } from "./icons"; +import { StatusDot } from "./StatusPill"; + +/** E-postadan iki harf: "denisergocmen@gmail.com" → "DE". */ +function initials(email: string): string { + const local = email.split("@")[0] ?? ""; + const parts = local.split(/[._-]+/).filter(Boolean); + const letters = + parts.length > 1 ? parts[0][0] + parts[1][0] : local.slice(0, 2); + return letters.toUpperCase(); +} + +export function Sidebar({ + open, + onClose, +}: { + /** Yalnızca dar ekranda anlamlı: geniş ekranda sidebar her zaman açık. */ + open: boolean; + onClose: () => void; +}) { + const { devices, now, email } = useApp(); + const pathname = usePathname(); + const activeId = pathname.startsWith("/devices/") + ? pathname.split("/")[2] + : null; + + return ( + <> + {/* + Dar ekranda çekmece açıkken arkadaki karartma. Tıklanınca kapanır — + çekmeceyi kapatmanın tek yolu küçük bir çarpı olsaydı, dokunmatik + ekranda hedefi tutturmak zorlaşırdı. + */} +
+ + + + ); +} diff --git a/dashboard/components/StatusPill.tsx b/dashboard/components/StatusPill.tsx new file mode 100644 index 0000000..18be3d6 --- /dev/null +++ b/dashboard/components/StatusPill.tsx @@ -0,0 +1,44 @@ +/** + * Durum rozeti — dört hâl (§9.3), tek yerde tanımlı. + * + * Daha önce durum, kart ile sağ panelde ayrı ayrı çiziliyordu: aynı renk iki + * dosyada iki kez yazılıydı ve birini değiştirmek diğerini sessizce eskitiyordu. + * Rozet artık nokta + zemin + kenarlık taşıyor; referans görseldeki gibi düz + * metin değil, okunur bir etiket. + */ +import { + STATUS_LABEL, + type DeviceStatus, +} from "@/lib/devices"; + +/** + * Renk anlamı taşır (plan §2): yeşil sağlıklı, kehribar uyarı, kırmızı kritik, + * gri "haber yok". Çevrimdışı bilerek KIRMIZI DEĞİL: makine bozulmuş olabilir + * ama kapatılmış da olabilir; kırmızı, olmayan bir kesinlik iddia ederdi. + */ +const TONE: Record = { + online: { dot: "bg-ok", chip: "bg-ok/10 text-ok border-ok/25" }, + offline: { dot: "bg-muted", chip: "bg-muted/10 text-muted border-line" }, + paused: { dot: "bg-warn", chip: "bg-warn/10 text-warn border-warn/25" }, + deleting: { + dot: "bg-danger", + chip: "bg-danger/10 text-danger border-danger/25", + }, +}; + +export function StatusDot({ status }: { status: DeviceStatus }) { + return ( + + ); +} + +export function StatusPill({ status }: { status: DeviceStatus }) { + return ( + + + {STATUS_LABEL[status]} + + ); +} diff --git a/dashboard/components/Topbar.tsx b/dashboard/components/Topbar.tsx new file mode 100644 index 0000000..1a5a1c9 --- /dev/null +++ b/dashboard/components/Topbar.tsx @@ -0,0 +1,196 @@ +/** + * Üst yatay araç çubuğu (tasarım planı §4). + * + * Planın istediği beş öğeden dördü burada: arama, zaman aralığı seçici, + * yenileme, uyarı zili. + * + * BEŞİNCİSİ — dark/light tema düğmesi — bilerek yapılmadı. CLAUDE.md §9.11 + * markayı tek bir koyu lacivert kimliğe bağlıyor ve landing (§9.12) de aynı + * dili konuşacak. Çalışan bir açık tema, ikinci bir tam palet ve her ekranın + * iki kez gözden geçirilmesi demek. Çalışmayan bir düğme koymak ise hiç + * koymamaktan kötü: kullanıcı basar, bir şey olmaz. + * + * Zaman aralığı seçici toolbar'da duruyor çünkü §9.8 "zaman aralığı TEKTİR" + * diyor — aynı seçim hem grafiği hem log listesini daraltıyor. Panel + * başlığında dursaydı, aralığın log listesini de kapsadığı görünmezdi. + * Cihaz listesi ekranında gizleniyor: orada zamanın bir karşılığı yok. + */ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useApp } from "@/lib/appState"; +import { buildAlerts, SEVERITY_LABEL, type AlertSeverity } from "@/lib/alerts"; +import { RANGES } from "@/lib/logs"; +import { relativeTime } from "@/lib/time"; +import { + IconAlert, + IconBell, + IconInfo, + IconMenu, + IconRefresh, + IconSearch, +} from "./icons"; + +const SEVERITY_TONE: Record = { + critical: "text-danger", + warning: "text-warn", + info: "text-info", +}; + +function SeverityIcon({ severity }: { severity: AlertSeverity }) { + const className = `size-4 shrink-0 ${SEVERITY_TONE[severity]}`; + return severity === "info" ? ( + + ) : ( + + ); +} + +export function Topbar({ onOpenMenu }: { onOpenMenu: () => void }) { + const { query, setQuery, range, setRange, refreshing, reload, devices, now } = + useApp(); + const pathname = usePathname(); + const onDetail = /^\/devices\/[^/]+$/.test(pathname); + + const [bellOpen, setBellOpen] = useState(false); + const bell = useRef(null); + + const alerts = devices ? buildAlerts(devices, now) : []; + // Zildeki sayı yalnızca MÜDAHALE gerektirenleri sayıyor. "Duraklatıldı" + // (info) kullanıcının kendi kararı; onu da saymak, kullanıcının bilerek + // yaptığı bir şeyi ona sürekli hatırlatmak olurdu. + const actionable = alerts.filter((a) => a.severity !== "info").length; + + // Menü dışına tıklayınca kapanır. Escape de kapatır — açık bir katmandan + // çıkmanın klavyedeki karşılığı budur ve fareye uzanmadan çalışır. + useEffect(() => { + if (!bellOpen) return; + const onDown = (e: MouseEvent) => { + if (!bell.current?.contains(e.target as Node)) setBellOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setBellOpen(false); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [bellOpen]); + + return ( +
+ + + {/* --- arama -------------------------------------------------------- */} +
+ + setQuery(e.target.value)} + placeholder="Cihaz ara…" + aria-label="Cihaz ara" + className="w-full rounded-lg border border-line bg-panel py-2 pl-9 pr-3 text-sm placeholder:text-faint focus:border-accent focus:outline-none" + /> +
+ +
+ {/* --- zaman aralığı (yalnızca cihaz detayında) -------------------- */} + {onDetail && ( +
+ {RANGES.map((r) => ( + + ))} +
+ )} + + + + {/* --- uyarı zili --------------------------------------------------- */} +
+ + + {bellOpen && ( +
+

+ Uyarılar +

+ {alerts.length === 0 ? ( +

+ Her şey yolunda. +

+ ) : ( +
    + {alerts.map((alert) => ( +
  • + setBellOpen(false)} + className="flex gap-3 px-4 py-3 transition hover:bg-panel-2" + > + +
    +

    {alert.title}

    +

    + {alert.deviceName} ·{" "} + {alert.at + ? relativeTime(new Date(alert.at).toISOString(), now) + : "zaman bilinmiyor"} +

    +
    + + {SEVERITY_LABEL[alert.severity]} + + +
  • + ))} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/dashboard/components/icons.tsx b/dashboard/components/icons.tsx new file mode 100644 index 0000000..5e4c1e7 --- /dev/null +++ b/dashboard/components/icons.tsx @@ -0,0 +1,207 @@ +/** + * Simge seti. + * + * Neden hazır bir kütüphane (lucide, heroicons) kurulmadı: tasarım planı + * ikonları navigasyonda, kartlarda ve log seviyelerinde istiyor — toplamda + * bir avuç simge. Onlarcası için ~50 KB bağımlılık taşımak "over-engineering + * yok" ilkesine ters düşerdi. Hepsi 24x24 viewBox, 1.5 kalınlık ve + * currentColor: renk çağıran taraftan gelir, simge dosyası renk bilmez. + */ +type IconProps = { className?: string }; + +function Svg({ + className = "size-4", + children, +}: IconProps & { children: React.ReactNode }) { + return ( + + ); +} + +/** Sunucu/cihaz — navigasyondaki "Cihazlar" ve kart başlığı. */ +export function IconServer(p: IconProps) { + return ( + + + + + + ); +} + +export function IconCpu(p: IconProps) { + return ( + + + + + + ); +} + +/** RAM çubuğu. */ +export function IconMemory(p: IconProps) { + return ( + + + + + ); +} + +export function IconDisk(p: IconProps) { + return ( + + + + + + ); +} + +export function IconActivity(p: IconProps) { + return ( + + + + ); +} + +export function IconList(p: IconProps) { + return ( + + + + ); +} + +export function IconSearch(p: IconProps) { + return ( + + + + + ); +} + +export function IconRefresh(p: IconProps) { + return ( + + + + + ); +} + +export function IconBell(p: IconProps) { + return ( + + + + + ); +} + +export function IconAlert(p: IconProps) { + return ( + + + + + ); +} + +export function IconInfo(p: IconProps) { + return ( + + + + + ); +} + +export function IconPause(p: IconProps) { + return ( + + + + ); +} + +export function IconPlay(p: IconProps) { + return ( + + + + ); +} + +export function IconTrash(p: IconProps) { + return ( + + + + + ); +} + +export function IconPlus(p: IconProps) { + return ( + + + + ); +} + +export function IconLogout(p: IconProps) { + return ( + + + + + ); +} + +export function IconChevron(p: IconProps) { + return ( + + + + ); +} + +export function IconMenu(p: IconProps) { + return ( + + + + ); +} + +export function IconClose(p: IconProps) { + return ( + + + + ); +} + +export function IconClock(p: IconProps) { + return ( + + + + + ); +} diff --git a/dashboard/lib/alerts.ts b/dashboard/lib/alerts.ts new file mode 100644 index 0000000..634c970 --- /dev/null +++ b/dashboard/lib/alerts.ts @@ -0,0 +1,131 @@ +/** + * Uyarılar (tasarım planı §8 "Recent Alerts"). + * + * TraceBox'ta uyarı diye AYRI BİR TABLO YOK ve bu dosya bir tane uydurmuyor. + * Buradaki her satır, zaten ekranda olan veriden türetiliyor: + * + * · cihaz sessiz → devices.last_seen eskidi (60 sn, §9.3) + * · eşik aşıldı → son metrik satırı agent'ın flush eşiğinin üstünde + * · eşiğe yaklaşıldı → eşiğin 15 puan altına girildi + * · duraklatıldı → devices.logging_enabled = false + * + * Yani "kritik" kelimesi keyfi bir tasarım tercihi değil: eşiği aşan bir + * değer, agent'ın spool'u beklemeden ANINDA buluta boşalttığı değerdir. + * Sunucuda bir alerting motoru olsaydı da tam olarak bu satırları üretirdi; + * fark, hesabın tarayıcıda yapılması. Cihaz listesi zaten bellekte olduğu için + * bu türetme fazladan tek bir sorgu bile açmıyor. + * + * Sıralama: önce ciddiyet, sonra tazelik. Zil menüsü kısa; en üstteki satır + * kullanıcının ilk bakacağı yer olmalı. + */ +import { deviceStatus, type Device } from "./devices"; +import { FLUSH_THRESHOLD, SERIES } from "./metrics"; + +export type AlertSeverity = "critical" | "warning" | "info"; + +export type Alert = { + id: string; + severity: AlertSeverity; + title: string; + deviceId: string; + deviceName: string; + /** Olayın bilinen en son anı; yoksa null (hiç ölçüm gelmemiş cihaz). */ + at: number | null; +}; + +/** + * Eşiğe "yaklaşma" payı. Kart çubuklarının sarıya döndüğü noktayla AYNI sayı + * (DeviceCard: danger - 15). İki ekranın iki ayrı "dikkat" tanımı olsaydı, + * sarı çubuklu bir cihaz zilde hiç görünmeyebilirdi. + */ +export const NEAR_THRESHOLD_MARGIN = 15; + +const RANK: Record = { + critical: 0, + warning: 1, + info: 2, +}; + +export const SEVERITY_LABEL: Record = { + critical: "kritik", + warning: "uyarı", + info: "bilgi", +}; + +export function buildAlerts(devices: Device[], now: number): Alert[] { + const out: Alert[] = []; + + for (const device of devices) { + const status = deviceStatus(device, now); + const seen = device.last_seen ? Date.parse(device.last_seen) : null; + + if (status === "offline") { + out.push({ + id: `${device.id}:offline`, + severity: "critical", + title: "Cihaz sessiz — agent ulaşılamıyor", + deviceId: device.id, + deviceName: device.device_name, + at: seen, + }); + } + + if (status === "deleting") { + out.push({ + id: `${device.id}:deleting`, + severity: "warning", + title: "Silme emri bekliyor", + deviceId: device.id, + deviceName: device.device_name, + at: seen, + }); + } + + if (status === "paused") { + out.push({ + id: `${device.id}:paused`, + severity: "info", + title: "Gönderim duraklatıldı — veri yerelde birikiyor", + deviceId: device.id, + deviceName: device.device_name, + at: seen, + }); + } + + // Eşik kontrolü yalnızca cihaz konuşuyorken anlamlı: sessiz bir cihazın + // son metriği saatler öncesine ait olabilir ve "CPU %95" demek yanıltırdı. + const m = device.latest; + if (!m || status === "offline") continue; + + const at = Date.parse(m.measured_at); + for (const series of SERIES) { + const percent = series.percent(m, device.ram_total_mb); + if (percent == null) continue; + + const limit = FLUSH_THRESHOLD[series.key]; + if (percent >= limit) { + out.push({ + id: `${device.id}:${series.key}:over`, + severity: "critical", + title: `${series.label} eşiği aşıldı — %${percent.toFixed(0)}`, + deviceId: device.id, + deviceName: device.device_name, + at, + }); + } else if (percent >= limit - NEAR_THRESHOLD_MARGIN) { + out.push({ + id: `${device.id}:${series.key}:near`, + severity: "warning", + title: `${series.label} eşiğe yaklaştı — %${percent.toFixed(0)}`, + deviceId: device.id, + deviceName: device.device_name, + at, + }); + } + } + } + + return out.sort( + (a, b) => RANK[a.severity] - RANK[b.severity] || (b.at ?? 0) - (a.at ?? 0), + ); +} diff --git a/dashboard/lib/appState.tsx b/dashboard/lib/appState.tsx new file mode 100644 index 0000000..5e0ab50 --- /dev/null +++ b/dashboard/lib/appState.tsx @@ -0,0 +1,152 @@ +/** + * Kabuğun (sidebar + toolbar) ve sayfaların paylaştığı tek durum. + * + * Neden context: sidebar cihaz listesini gösteriyor, toolbar aynı listeden + * uyarı sayısını türetiyor, cihaz listesi sayfası aynı listeyi kartlara + * çiziyor. Üçü ayrı ayrı sorgu açsaydı aynı satırlar üç kez inerdi ve üçü + * birbirinden birkaç saniye farklı bir "şu an"a bakardı — sidebar bir cihazı + * çevrimiçi gösterirken kart çevrimdışı diyebilirdi. + * + * Arama kutusu ve zaman aralığı da burada: ikisi de TOOLBAR'da duruyor ama + * içeriği SAYFA çiziyor. Aradaki tek bağ bu nesne. + */ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { fetchDevices, type Device } from "./devices"; +import { RANGES, type RangeKey } from "./logs"; + +/** + * Liste 10 saniyede bir yenilenir — agent'ın komut poll'u ile aynı ritim. + * Daha sık sormanın anlamı yok: last_seen zaten en fazla o kadar tazeleniyor. + * + * Burada Realtime KULLANILMIYOR. Realtime, son 24 saatin logları için ayrıldı + * (§9.9); orada gecikme ürünün vaadinin parçası. Listede "12 saniye önce" + * yazısının bir saniye geç güncellenmesi kimseyi ilgilendirmez, buna karşılık + * her cihaz için ayrı bir abonelik açmak gerekirdi. + */ +const REFRESH_MS = 10_000; + +type AppValue = { + devices: Device[] | null; + error: string | null; + /** Saniyede bir tikleyen "şu an" — "12 saniye önce" ve çevrimdışı rozeti. */ + now: number; + refreshing: boolean; + reload: () => void; + query: string; + setQuery: (value: string) => void; + range: RangeKey; + setRange: (key: RangeKey) => void; + /** + * Aralığın SABİTLENDİĞİ an — saniyede bir tikleyen `now` değil. O olsaydı + * pencerenin iki ucu da her saniye kayar, grafik ve log listesi durmadan + * yeniden çekilirdi. Yalnızca kullanıcı aralığı değiştirdiğinde tazelenir. + */ + anchor: number; + rangeSeconds: number; + email: string; +}; + +const Ctx = createContext(null); + +export function useApp(): AppValue { + const value = useContext(Ctx); + // Sağlayıcı dışında çağrılırsa sessizce undefined dönmek yerine patlıyor: + // aksi hâlde hata, aylar sonra "cihazlar niye boş" diye görünürdü. + if (!value) throw new Error("useApp, AppProvider dışında çağrıldı"); + return value; +} + +export function AppProvider({ + email, + children, +}: { + email: string; + children: React.ReactNode; +}) { + const [devices, setDevices] = useState(null); + const [error, setError] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const [refreshing, setRefreshing] = useState(false); + const [query, setQuery] = useState(""); + const [range, setRangeState] = useState("24h"); + const [anchor, setAnchor] = useState(() => Date.now()); + + const setRange = useCallback((key: RangeKey) => { + setRangeState(key); + setAnchor(Date.now()); + }, []); + + const load = useCallback(async () => { + try { + setDevices(await fetchDevices()); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + /** + * Elle yenileme, otomatik yenilemeden bir noktada ayrılıyor: dönen spinner. + * Otomatik turda spinner göstermek toolbar'ı 10 saniyede bir kıpırdatır ve + * göz oraya takılırdı; kullanıcı butona bastığında ise bir şey olduğunu + * görmesi gerekiyor. + */ + const reload = useCallback(() => { + setRefreshing(true); + load().finally(() => setRefreshing(false)); + }, [load]); + + useEffect(() => { + load(); + const refresh = setInterval(load, REFRESH_MS); + const tick = setInterval(() => setNow(Date.now()), 1000); + return () => { + clearInterval(refresh); + clearInterval(tick); + }; + }, [load]); + + const rangeSeconds = + RANGES.find((r) => r.key === range)?.seconds ?? RANGES[0].seconds; + + const value = useMemo( + () => ({ + devices, + error, + now, + refreshing, + reload, + query, + setQuery, + range, + setRange, + anchor, + rangeSeconds, + email, + }), + [ + devices, + error, + now, + refreshing, + reload, + query, + range, + setRange, + anchor, + rangeSeconds, + email, + ], + ); + + return {children}; +} diff --git a/dashboard/lib/metrics.ts b/dashboard/lib/metrics.ts new file mode 100644 index 0000000..353ab18 --- /dev/null +++ b/dashboard/lib/metrics.ts @@ -0,0 +1,312 @@ +/** + * Zaman çizelgesinin veri katmanı (CLAUDE.md §9.6–§9.7). + * + * Buradaki tek büyük fikir: HAM VERİ İNMEZ. Agent 5 saniyede bir ölçüyor, yani + * tek cihazda 10 gün ≈ 173.000 satır. Grafik ise ~1200 piksel geniş; satırların + * neredeyse tamamı zaten çizilemez. Bu yüzden seyreltmeyi veritabanı yapıyor + * (db/migrations/0004_metrics_buckets.sql): aralık ~1000 kovaya bölünür, her + * kovadan min / max / ortalama döner. Hangi aralığa bakılırsa bakılsın ekrana + * aynı boyutta veri iner — 1 saat de 10 gün de aynı hızda açılır. + * + * Ortalama TEK BAŞINA kullanılmaz (§9.6 madde 6). 15 dakikalık bir kovanın + * ortalaması, 5 saniyelik bir CPU patlamasını yutar; o patlamayı görebilmek bu + * projenin varlık sebebi. Grafikte bant min–max, içinden geçen çizgi ortalama. + * Yalnızca max çizmek de bir tür yalan olurdu: makineyi olduğundan yoğun + * gösterirdi. + */ +import type { LatestMetric } from "./devices"; +import { supabase } from "./supabase"; + +/** SQL fonksiyonunun döndürdüğü satır. Alan adları birebir aynı. */ +export type MetricBucket = { + bucket_start: string; + samples: number; + cpu_min: number | null; + cpu_max: number | null; + cpu_avg: number | null; + ram_min: number | null; + ram_max: number | null; + ram_avg: number | null; + disk_min: number | null; + disk_max: number | null; + disk_avg: number | null; +}; + +/** + * Kaç kova isteneceği. ~1000 ekranın çözünürlüğüne oranlı sayı (§9.6 madde 4); + * fonksiyonun kendi tavanı 5000, yani bu değer büyütülse bile veritabanı + * korunuyor. + */ +export const BUCKET_COUNT = 1000; + +/** + * Agent'ın ACİL GÖNDERİM eşikleri (agent/config.example.toml: cpu 90, ram 90, + * disk 95). Grafikte kesik çizgi olarak duruyorlar, çünkü o çizginin üstü + * keyfi bir "tehlike" tanımı değil: agent orada spool'u beklemeden anında + * boşaltır. Kart ekranındaki çubuk renkleri de aynı sayıları kullanır — iki + * ekranın iki ayrı tehlike tanımı olsaydı kullanıcıyı yanıltırdı. + */ +export const FLUSH_THRESHOLD = { cpu: 90, ram: 90, disk: 95 } as const; + +/** + * Sayı biçimlendirme burada, SERIES tanımının ÜSTÜNDE duruyor: SERIES bir + * `const` ve içindeki oklar bu iki yardımcıyı çağırıyor. Aşağıda tanımlansalar + * modül yüklenirken henüz kurulmamış olurlardı. + */ +function tr(value: number, digits = 1): string { + return value.toLocaleString("tr-TR", { maximumFractionDigits: digits }); +} + +/** 6820 → "6,7 GB". Kartta MB yazmak okunaksız. */ +function gbShort(mb: number | null | undefined): string { + return mb == null ? "—" : `${tr(mb / 1024)} GB`; +} + +export type MetricKey = "cpu" | "ram" | "disk"; + +/** Bir kovanın tek bir ölçü için üç değeri. */ +export type Band = { + min: number | null; + max: number | null; + avg: number | null; +}; + +/** + * Bir ölçünün TAM tanımı: adı, birimi, rengi, kovadan nasıl okunacağı ve son + * metrik satırından yüzdeye nasıl çevrileceği. + * + * Liste ekranındaki çubuk da, detaydaki grafik de, zildeki uyarı da buradan + * besleniyor. Daha önce üçü ayrı ayrı biliyordu: RAM yüzdesi iki dosyada iki + * kez hesaplanıyor, renk üçüncü bir dosyada seçiliyordu. Bir ölçü eklendiğinde + * (ör. ağ) üç yeri birden hatırlamak gerekirdi. + * + * Renkler HAZIR SINIF ADI olarak duruyor, `stroke-${key}` gibi kurulmuyor: + * Tailwind kaynak dosyalarını metin olarak tarıyor ve çalışma anında birleşen + * bir sınıf adını göremez — üretim derlemesinde o renk CSS'e hiç girmezdi. + */ +export type SeriesDef = { + key: MetricKey; + label: string; + /** percent → y ekseni SABİT 0–100; mb → tavan cihazın toplam RAM'i. */ + unit: "percent" | "mb"; + /** Tasarım planı §2: indigo primary, violet secondary, cyan I/O. */ + tone: { + line: string; + band: string; + bar: string; + text: string; + chip: string; + }; + pick: (bucket: MetricBucket) => Band; + /** Son metrik satırından 0–100 oranı; ölçülmediyse null. */ + percent: (m: LatestMetric, ramTotalMb: number | null) => number | null; + /** Son metrik satırının okunur hâli — kartın sağındaki yazı. */ + display: (m: LatestMetric, ramTotalMb: number | null) => string; +}; + +export const SERIES: SeriesDef[] = [ + { + key: "cpu", + label: "CPU", + unit: "percent", + tone: { + line: "stroke-cpu", + band: "fill-cpu/15", + bar: "bg-cpu", + text: "text-cpu", + chip: "bg-cpu/10 text-cpu", + }, + pick: (b) => ({ min: b.cpu_min, max: b.cpu_max, avg: b.cpu_avg }), + percent: (m) => m.cpu_percent, + display: (m) => (m.cpu_percent == null ? "—" : `${tr(m.cpu_percent, 0)}%`), + }, + { + key: "ram", + label: "RAM", + unit: "mb", + tone: { + line: "stroke-ram", + band: "fill-ram/15", + bar: "bg-ram", + text: "text-ram", + chip: "bg-ram/10 text-ram", + }, + pick: (b) => ({ min: b.ram_min, max: b.ram_max, avg: b.ram_avg }), + // RAM tek başına bir oran değil: 6 GB kullanım 8 GB'lık makinede kritik, + // 64 GB'lıkta sıradan. Oran ancak envanterle birlikte anlam kazanıyor. + percent: (m, total) => + m.ram_used_mb != null && total ? (m.ram_used_mb / total) * 100 : null, + display: (m, total) => + m.ram_used_mb == null + ? "—" + : `${gbShort(m.ram_used_mb)} / ${gbShort(total)}`, + }, + { + key: "disk", + label: "Disk", + unit: "percent", + tone: { + line: "stroke-disk", + band: "fill-disk/15", + bar: "bg-disk", + text: "text-disk", + chip: "bg-disk/10 text-disk", + }, + pick: (b) => ({ min: b.disk_min, max: b.disk_max, avg: b.disk_avg }), + percent: (m) => m.disk_percent, + display: (m) => (m.disk_percent == null ? "—" : `${tr(m.disk_percent, 0)}%`), + }, +]; + +/** SERIES'i anahtarla aramak için — uyarılar ve rozetler tek ölçüye bakıyor. */ +export const SERIES_BY_KEY: Record = Object.fromEntries( + SERIES.map((s) => [s.key, s]), +) as Record; + +/* --- Çekme ---------------------------------------------------------------- */ + +/** + * Üç ölçünün üçü de TEK istekle geliyor: fonksiyon zaten hepsini birlikte + * döndürüyor. Üç ayrı çağrı yapmak aynı satırları üç kez taratmak olurdu ve + * grafikler birbirinden farklı anlara ait olabilirdi. + */ +export async function fetchBuckets(params: { + deviceId: string; + fromMs: number; + toMs: number; + buckets?: number; +}): Promise { + const { data, error } = await supabase().rpc("metrics_buckets", { + p_device_id: params.deviceId, + p_from: new Date(params.fromMs).toISOString(), + p_to: new Date(params.toMs).toISOString(), + p_buckets: params.buckets ?? BUCKET_COUNT, + }); + + if (error) throw error; + return (data ?? []) as MetricBucket[]; +} + +/* --- Çizime hazırlık ------------------------------------------------------ */ + +export type Point = { t: number; min: number; max: number; avg: number }; + +/** + * Kovaları noktalara çevirir. Üç değerden biri bile null olan kova ATLANIR: + * eklenti kapalıysa (§4.2) sütun boş gelir ve 0 kabul etmek "kullanım sıfırdı" + * demek olurdu — oysa doğrusu "ölçülmedi". + */ +export function toPoints(buckets: MetricBucket[], series: SeriesDef): Point[] { + const points: Point[] = []; + for (const bucket of buckets) { + const { min, max, avg } = series.pick(bucket); + if (min == null || max == null || avg == null) continue; + points.push({ t: Date.parse(bucket.bucket_start), min, max, avg }); + } + return points; +} + +/** + * İki komşu nokta arası bu katsayıdan fazlaysa arada VERİ YOK demektir. + * 1.5: bir kovalık boşluk yuvarlama payı, ikisi gerçek boşluk. + */ +export const GAP_FACTOR = 1.5; + +/** + * Boşlukları ayırır. SQL fonksiyonu boş kova döndürmüyor (cihazın kapalı + * olduğu 9 gün için 900 boş satır taşımak anlamsızdı); boşluğu iki komşu + * kovanın zaman farkından burada çıkarıyoruz. + * + * Ayrılmasaydı grafik, cihazın kapalı olduğu günün üzerinden düz bir çizgi + * geçirir ve o boyunca veri varmış gibi gösterirdi. + */ +export function splitOnGaps(points: Point[], widthMs: number): Point[][] { + const segments: Point[][] = []; + let current: Point[] = []; + + for (const point of points) { + const previous = current[current.length - 1]; + if (previous && point.t - previous.t > widthMs * GAP_FACTOR) { + segments.push(current); + current = []; + } + current.push(point); + } + if (current.length) segments.push(current); + return segments; +} + +export function bucketWidthMs(fromMs: number, toMs: number, count: number): number { + return Math.max(1, (toMs - fromMs) / count); +} + +/** + * Y ekseninin tavanı. + * + * Yüzdelerde SABİT 100 — veriye göre ölçeklenseydi %2 ile %3 arasında gezinen + * boş bir makine, ekranı dolduran bir dağ gibi görünürdü. Grafiğin yüksekliği + * her zaman aynı şeyi ifade etmeli. + * + * RAM'de tavan cihazın toplam RAM'i (künyeden). Envanter hiç gelmediyse + * gözlenen en yüksek değere düşülür — o durumda oran anlamını yitirir ama + * grafik yine de çizilir. + */ +export function ceilingFor( + series: SeriesDef, + ramTotalMb: number | null, + points: Point[], +): number { + if (series.unit === "percent") return 100; + if (ramTotalMb) return ramTotalMb; + const observed = Math.max(0, ...points.map((p) => p.max)); + return observed > 0 ? observed : 1; +} + +/** Ölçünün eşik çizgisi, y ekseninin biriminde. */ +export function thresholdFor(series: SeriesDef, ceiling: number): number { + const percent = FLUSH_THRESHOLD[series.key]; + return series.unit === "percent" ? percent : (ceiling * percent) / 100; +} + +/* --- Biçimlendirme -------------------------------------------------------- */ + +/** 86400000 → "1 gün", 154000 → "2,6 dakika". Kova genişliğini yazmak için. */ +export function formatDuration(ms: number): string { + const seconds = ms / 1000; + if (seconds < 90) return `${tr(seconds)} saniye`; + const minutes = seconds / 60; + if (minutes < 90) return `${tr(minutes)} dakika`; + const hours = minutes / 60; + if (hours < 36) return `${tr(hours)} saat`; + return `${tr(hours / 24)} gün`; +} + +export function formatValue(series: SeriesDef, value: number): string { + if (series.unit === "percent") return `${tr(value, 0)}%`; + return value >= 1024 ? `${tr(value / 1024)} GB` : `${tr(value, 0)} MB`; +} + +/** + * İmlecin altındaki kova. Kovalar zaman sırasında olduğu için ikili arama; + * 1000 nokta üzerinde her fare hareketinde doğrusal tarama yapmak, kaydırma + * sırasında hissedilir bir takılma üretirdi. + */ +export function nearestIndex(buckets: MetricBucket[], t: number): number | null { + if (buckets.length === 0) return null; + + let low = 0; + let high = buckets.length - 1; + while (low < high) { + const mid = (low + high) >> 1; + if (Date.parse(buckets[mid].bucket_start) < t) low = mid + 1; + else high = mid; + } + + // İkili arama t'den küçük OLMAYAN ilk kovayı bulur; bir öncekinin ona + // gerçekten daha yakın olması hâlâ mümkün. + const previous = low - 1; + if (previous < 0) return low; + const here = Math.abs(Date.parse(buckets[low].bucket_start) - t); + const before = Math.abs(Date.parse(buckets[previous].bucket_start) - t); + return before <= here ? previous : low; +} diff --git a/dashboard/lib/time.ts b/dashboard/lib/time.ts index 43795cc..4e3bfc0 100644 --- a/dashboard/lib/time.ts +++ b/dashboard/lib/time.ts @@ -54,3 +54,23 @@ export function localDateTime(iso: string | null): string { minute: "2-digit", }); } + +/** + * Grafiğin x ekseni etiketi — YEREL saat, aralığın uzunluğuna göre kısalır. + * + * Saniye YOK: eksende beş etiket var ve aralarındaki mesafe dakikalarla + * ölçülüyor; saniye yazmak okunacak bir bilgi değil, göze giren gürültü olurdu. + * 36 saatten uzun aralıklarda gün de eklenir — yoksa "03:00" iki farklı güne + * ait iki etikette aynı görünürdü. + */ +export function axisTime(ms: number, spanMs: number): string { + const d = new Date(ms); + const clock = d.toLocaleTimeString("tr-TR", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + if (spanMs <= 36 * 3600 * 1000) return clock; + const day = d.toLocaleDateString("tr-TR", { day: "2-digit", month: "2-digit" }); + return `${day} ${clock}`; +} diff --git a/dashboard/public/tracebox-logo.png b/dashboard/public/tracebox-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f8469235d7032ed6fb96fcf38196e66ee2b66845 GIT binary patch literal 266352 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≤DK_k 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%0AO5)Xlljy}y8wg!^m_mV% zFTCzrQn01Cd1){s@^CG3Lb-2%>DMzynS5u(h1Z+``y-5upD-mj-eo+asfNvec={9G zA_qc91}_sr9dX*bKFEuFH1h;MOyaO<^<%RgTBTPG`APze!GE{3HaLTIhQs<&mtNAE^$R|^Jw2ka zEHh6fwrz|%xTD;+6a>k60;qsGuqz)u*7P{F!T+3pqc6}@aNPNEDWP0JNMjuE9nXjJ zgf+z>Zw}6bz#kA*pe^q^?Gu`GH1qF2gHeOkVQ{4t_cw8gqQ}cBZOo=!j}P>@aYGZH zAAx5eHL_wHmC$8DP>=2kChHHqC>9s8y&c{&ldTCGX7;kZjSDz<@|TWmH9UjUt7pa{ zethJ&ZGPCsitM696(;_y!qz$NM%~jg`_U6!&9jt6U;^nD@Uz!0N+ZuSfs{|7a05AqSzL zyq(Cv=@2^i7fTzWM|ewIyl!m%H=O?m;Zp(OFrvYOy=mPAWmw_qAglF3lv|bSS`W(c zm_`wp!z*#Z+ocgV1NvJeVutXbtLpI>#GkP5-y-ME$Pp9GCe=o z{R&r~gSu@2U!r#gK*F|ha8P*QT?@WgSRlb_#fW9IN|(j{(oEx>=v25#nYX`dOh>#B zDD0ABCUTfd)AX|Y3MaP_8nubhKWrEiWd7Z+uImtTo_laJ?s$*rhl&f8D9%-EDUBX+`B9SWht`YBy8}4)qjCzkUC(^^^;H`m1#A?k-z=v9aUN*rMe&sI z5T^Yt2_yq>Z5b_7x4J|G=2HYj3q$f=0jRD-5^jdb4{LIyUpS!v3w1tW)AW^&irt!A z67DEKt;2JRp|i5?uNh_MuQz8-jtz+KYFyl9AAYwQ*r~VZdRFiKnlZWmE z+LZkFabJ*TN~s^>W>Fx;&b1@L5Tn(2xBQFW%y#cw{ogUMdbSl^n6_%|!|F{#)8k?4s$oVQl& zJP9B~%`OXDqj?5s*@ZL?Y0vFjI`r+&D>oG1U~NAoo<9zH z#^?ddjKRxWEyBfbG%^G-$a7Ir5j6>W7bopYpRRMLHmORj*fWm*Y)P-08GMPY<3A=p5PPTiIBr(^GD+FD-$ zsMXTHKiCpJc2C__1lTB(WcB3%>+$n`;`Drd;tE0Qw&mUH&ZWxHA<@Wdt)m9H6&k7Aqh1ZF6LMx!$ zKdH~(L7qHVdK|CA%;+khk*$raAYaD!KiY`@=vhcY0e5}YlYChWQ+Xu8Cw7^3IsANn zK#_8)-FJ|f@(}sx@CJn6r)|uNDg%#I%084-Ph40_dxX~;;v!26vSdbau3M1u5-+Y= z(~yxK12yCAqCI5`6J+n`xQQo9M1GAn=u&g8lqW-AUz?C4^e;poAw*{cd%9+YrYCF))T+m4Mnevh*G87kwGe5GA zT@6JHmlgGe13Ndi@Bx^wXKL=X*%I#F#$3_M?gbfqE&L>Q5obOq?k*6wi3?|7B@x!sPrU(K7+6K^PJZuQZdOF<%5dJbrIA3GiR%lJ)q?MCH~UxypMAIMB}b z&EQ=<{xBMxQc{Xg1Td{#yUIjLQrFL!#&y%FuGr5@9^aBgz5!3n!hWxIk0TYs9uRIQ z7Zx}`TTWHMuknkmxSAr5XS_peaDHPDp@P&794fR`UR^}yC=9w`GRg65!$x4%vZ_Cc zeJAN{BTGtovbb}`LhMi#EOsGBtk23IPtmc0*tEM&I4xIfW{_|Qlm8|HM8;@JS8<}FBxHSww$&*psLb6nVaE;=60oO@mw|FPgL zkgOs~_>bt65JcnMiSO@L6DkG-YY)D#3eBhDotp_Q-5R>pxsqpD)WshoKfr0D1+J~` zmeb%}YfH73uqbK#_u#TPDh+uuoz2%;#f8SNLQK#XTwW-8n6hhTEL4v~%m-8q>I1D= zm4t!``#c?X$BLo-4m4B;ixcW_< zzk0n$u^4cNoFavG@=xT3O1zPS+5snl=3UQ*A|K0I{67BzF8%iMACa|Zg3H*uwjn>< zR}>0Q%y{)i;cR8`+NMk0!A+GX@p_ms{Rpbf5+3qn36QKknljCZ!RPIwb8b~djbPOC zZ;r0w>25`d%|mF^corWy2}=eHm8lwrr?k7IRYrLxQ>ESo?N9+ifJg1W^OYws#ZeG? zWTb7!_o^PmM}-~5-Zq4k_Q-sbQ5M!@BTtqc+Mfj-&~n0=WvKs<$g(K{(3uyW+W}(x z4waALv>%TZd={tvUgzDo7|7AxlE|Rw)KyqaClN z7#Ml$F4E!xur?{-lV$TwpL53g!y(<<&iPVpSQhH<8tKvIGd15=GVf=jdev{lPY-z= zJyNCgHmi#^8Ga>sxHeQLJrm!$ieD;HaAv2|ID~W$D!}V?_kZiFZ?b0}unrSz`AM)Z z-(J0Q^8ROa$0L%*dZG6nyFq5V;91!7y`bWOw2PD@L+FjHH(pRjtDMLzh3spx;?f(^ zyGN$*3l$E)@$|4(G+$&n{9B+#VE&th#~5qI05YAfy_pm5gWpa_d^K(3kIwQX4N`~S zDjB>@UuLgMguSBja4)p@j!4s~f%L*Ka#eh4s;Dq2ia!T8CV=^nAh*Wo7y#%U^zP9Z zws>HdRb| z=fBgmV}zaD+vbAc>X%y=LEZB;Xjd%<&kF=S6UMs;BAI&lhXBg4@9o#e94Q z83OTexR6{sSbRG85Dre1|Bvz9^ZT#4+3UcSYwSsD^=7{9l#g((kw}*Ki1n_W{{w26 zEpyZxR$_-GRvjzvh+ZPdYn}iXx|unRR0e3yDg9z^M_M=bfim4r?;tNvajO2>MVeY_ zGt${r81CvY{z`mjo!V@&$(s{VJ^?zA0Dc#y+smQ#NmQD&O_h>lqh0Fi4G4ZSGStV%HR{L~JNHHfmY0Cz0oxn_^1xj&?Ptljj9K zfSra<98G_D)Y~1fqmB*#0TRO;T=Oc{LoQ@hxBcrTkzYZh^MEY`bvElit38x|Hyed- zDyGO$Z)Wk!@J#dw-ZS+SsUtEO@Z#g9<#M8LKZ0u)BPuUGg|ZUI$99ppS$(1rUWwK& zWZM!V>&b1caXz!@>kFNnh%>~m@Grv(7o_#U9p7UsJ2F6VXS}|A$w3h?-A`@!s#0S& ztQ0An9cW+A1g1&UDtOTXa0T+}%L1r0?h^$(odm{uEh25;3V`uu5) zg0~|!oPX7iA>Tt-qQ&8FT4cOMThFsy_?@TR`=l0 zX0vMpi+Sp{lnMNH$Rj>@Kqsz{9&Q(H$dvM}dZJe9{J-!?Vwq{NHQC27?OAuegGRoG zIy9A)7I^XzITCybr9@iH&Rz2_Nx!IRf}wmPtG7b`u|;FQ^#poc?tW?)zv_}U^x54c zpAkcFuJnlKH=S;&a?^Du8oxIW z$QJRN1>xj{eoFAedv=rf4bq!2i>v(cG!+y@9Z9_#@bqRdN0W}QF^``QoPxlBko!Yn z!qmgClmN&(kcI7AMkBcgt`yHHTF;44x=nStDBg4UT|Fp&O?aIwaH!8df|REwH&K49 zM-^sWT+g`iC734pupDF_o4(&TmR3Y&xn0GiwFh^PLhi>zX&S_$E0}>>&-gZ|3bQyD@ocVqrW#|pVTP~6{DfAe!f{5Qt-XQcW|QnVJbjOA1K z{2?@Yl)w*OodEggwJ0cs0n!O$#gnG4VJi|rtpi18$gr!CHk^EIq#<1I_#Y5IL`Lay z!f)suU1mhmIDka(#)mGwf?_%#z$2ELS1Qc9C11di6)t?s4}`$S#a~XC0-}vy#y2{emNoBXy|eaW_Z4yvQ?Nly@ZVM0 z3n^z8yWmjJ{hMrk!lP7MEksAxg$0gVR}RY{A7-9$i@)s*ww$jXl=gZL@|%@%6pQ42 zwU!5R{ZGq0$^6+VftXbx*$UsTCc-|dV3c!zLta=oXm4Dw;yn=<$BQdDTjHtU5(Lw_|%j)A(XC2@1Ikr!wCPI%MA6zyW!+{kHrw1Qry2I-ihL{@1Qf%NbIt z!odpr30ACvquHyY7;zDCT-2UdF*4ZlZpl%i$J+N$jYp4MGj)__9)OvG8DO3lBe~aQ z;_*sZpdd15)X^Di7`IUtJyeT#~GOk3Z4`5Ze(qWbo9 z=jAm%L|0_+VtP#{GP;*kD!3>%mFLGkDL8?aKlIjl9_k`J@_PHnE0P*C^lExMnhJHA z*1HOct8`*B>X8!@b1A2}P9)fz{*R`!@QR`V_wej4-QBT-Gy+Q3E+wEy7)Y0bNJw`r z-61F-4WeL>;wR10AV@3CQVK}t0$X?QJ?H)lGjq<)l*$Of^B364f~kn7&GV zGtZ*ET+bpB?RT4B5W${GJa?L+o@ zM#Upo+w!afS6}HnEIpdAWf%N0+x{*oP{DnG?@D`ti{i_~%tK3t9g}!V^5S+VNRfSz&J^V+a75cOMr~i%e`w%j9GBNWU z3mV#$M(59c;SO{E`-0fJ_O@i?mB%t&Q2cKAM~9bM90%kz0l_cfwn@ug9XVHsx|SW8 zDoRcrmer(p#*b3y=Z^%cZd4%`3AYDf1Q@1$jyKCYJpJ8y9j9@} zdBfQEZge2V*w*pSS00nL=lD7$pseJF^?%`4{B3)`AqPjsx45Cso{0A_`12dgdj{kQ z6l=x&iXQStVCw!+%NJ1Ky#RGPV1P@*mIw|{$M3Dd?l1OG2M*1knrhyDTpj_ro(xAuGBadT6EUKK zC2v6wU_yF-@h~d-jst{aQk`D(7cVH#=!f!yTY!Y4Rg^;VC#SMhnut0GDa$y;%a|sf!03(1rg{)_aYM z(G}CR8fzrkUdjMP@w%?NQOYAR#tIcrL{W?1plv@ne>fQ#9F>}>iNC9L=zEfjaV(i| zNsW@WCY8@4dt$p0I{ajogwmOK*xRYW3&N7^#cs9V@i-EHxTVdrC%0({r$b0=AeYDU zI7?%b##e)a0WE%@lqP?i1>8-B^dkGAeh6}B+GHZV*Fg!>Wkhjx5--h0AKM5Y`-t5xG5`~R6hof&#$uwjh{IxuclYOs4rM`dlnVpLsm~G7)LH}cM ztY0}MukY(p{Q!PJ(G31KJ9oPAp|=TB!2$O99&G3Nmiu9}iCN>%|9+at<*2seaRWi6 zR9^~Kc*RP!2(K8fN+}NKfM@B5B z?DF0Dp?5t9KWj_h>qq^q_L*qnsmqm%1-ep>;Vl7yCfvxgi|>!({UYc06)&T?nrYH{ zy-?`jByl6D%P1arO9tolm7a(;Mc4#SZv0yfK;WN`eu`#>lVuODMB@fG&U*CSS7jeU zQJ&+*hvE;9iYK5h$=s)=G5K2z9QTh{b(*69rKd?aJ%}j>nlj>XMpLa=uG3MNENH?C z)2at6@Q#hAV&*Ggp!X%O@PYLr+3*z>5`BB3{TFyD;|nfOO@z$Mbrgl?PSCNl%k^O9 z{vvKCO6}=tTP*?vDfQHXlzpg?qDYdYY+kNw84DcbM+$-#Eg=q>_PMy5osf>Re+u0F z0kg|S;%;|hKA;JczFVsl@VHfi#5%3|(FA0~CM+KxE-;IC4H7sZ4URX>_p0 z?Q;k=v-IO7Oz;(WlYZ4|wdppVk_ma>AmR@dj^w?gi{wS@e#9we!-@rPfM zvfxP1cvSCbDlEmKZtOoU-0~1m0NJoYe+i6oscx|05ZVZ5GH()OIPJK~Cyf$LO^^-$ z^W;t(^kthK-Mr zZqEC*zpNXl0`eZWDAzbHD?eAPu^n{A*+(GxVdZ50RiviM57hQ=Mt~j^TdsYdLNa|5 zL{?08PM0{5?Gd)Ll^OV6vnU@b98t~Xq*zwyl0x3D+w6qyeHGl21f{5xYv@mpi-c5} zio`yk-Vn>5iPqj5Hn=X=rpZ8m2)0Cc?s*HK7c#E`+uxKtf%{Epd}T4NFEx8=y7Ps) zqJ$o?Cf*m^YX3v-GP&LmRSW#^{K4C^a@1iA@LZ369mB&$&jzjVzLqG(L11WpEv&Ee zZ`43f)PHW^)xdY!-f0L96^1H;bfMX;b8ayp7s6C;%oDM9TnP+4=EGcy1lr&$v|>14 z?3YsvuHSI@@3ys3k$h}f)azqfz7rnCMen@KN=+JE$XUZ$6GlG($vh#vI$jd$_BtBJ zZ$=9dBtv-4up7bQ+1u>am z#|u`TlHc|)dxZc{i~WE*SJ9U0zAt(8i)!aE!WFIgEsDG~zjRvUHgSz>^hOWerFa*U zc^?tCw(OGBwsj0H-l~D@MXng1Ke+kU-ylL|kHVI^`cXGeFZHUJwj62PpSXPR&fhpC zfQNMAfoV8yIf$@PiPQf^|AA0WAQT%l z%l%_-AK)BH?#hTy1W?E~YUkPwZN$Wyb#+dfNtZTl#&bX-T+Z@L=2Og_xCq1VyG)<*=4mM$s? zbz0qgHnrRT_z8;Yq{q7djkit2zUTVF?jewf4C~(~gc>YG911LWV%Yd*8Whw)NhF;#w#H8hmZRyqkSW$_PRPO774_^>hx@pF*=8811L(n-|f-xO} zdUqwy@#ZIBU)Z|8_ecBI?gRy& znSsn8x?k8?QX5`(izcu1*qhCCS?!?J5P$=%PLo2&_mN4qe%Z#JRRSLVI-JgV%AXis zWyV1K`ZnbDdH+Vb>dK<;iP8N4pLVR5)P(?)L3wqyU?Dz28FYt)Hh!4pXGFU2&e9~) zhl>&nnoUT*6OQHcxJ-CBUxtkD%KGl}~c9q66v>(75srBBF!e2VrHn;Qha^0;3taksN zOj-+L9~tLSZ!*)t*vj_BQ@&&w7wJge;W7a-^G`{R%kW1QeQ;)MJ>!I7AQwLK$2Lm# zCeZ!LyGQ#scKlwH9J59cAioziMfjQkmEBj;*>iW<>iV^aY2ySf)?A>z@cg&fl}Z`w zy^6DGdkMLEUx&rfPu%aKC54u&Z*Tla{}Q*4zcFmwhGS*1qz??|KHCoyM^;n!MVL16 z^DBe%F+oYj#ce6L3pIe;@oy77=S!Q;taHusty*;ec={n>Fytf1Ke#PqKsO}z6@wh+40(-A73 z#4e^pcBC2U80UnZhW%~aPfzR8X!W}8i8>jY>>mr|@0&NXCHrL%<~6q8Ev4c|Q&5Pu zlNF4;>4(@r?=O!(vr=~}k+fg(Zr&*;{q;g=e_S36nyJb#Bw1cGApcUaf4GW6Ze6}5 zrk|2f5a!8O=!ecH?C3XfnQAB68s~W&tcYHacNU|@pRhIfUg&RK14R~yLCyZ7htEqp>bTZzHlJV0VkTYzFXhcO*>ArnsJW7fF=;Wv&t}l9M za#j1eOlkf$*y*czhgB|no$pY1yhL)nY>6*8<+>h>|FQj?jaNuyfX)t4J6!0MO-l22-daD?k~YYOxJ9477Wo?>Yt#(F|Mz8g2N z_Ag%(Gt0)}A9rVXXGelpMysE9ogNJKKaITuZ+{Uqb#5HC^H|Ph^x3ic94Iq^ENI40 zCJQq=Z2}`FDO>aRU*3glcOJB2XLb2Q32jC-z_A;kVa}e@tXL*#fR#4CEZ+4 z8>+g5%nx+V66q3E7g#c~HQFhf+U8w_N35po4*!)xAINc!ij)xIdR8iQ@2LCe>HxzR zyCD2z<QWA z;KZ*&`YC^vMV^{bERNWUkU0#0@0Sw8-*z$YO?8iMrZ$J%67YT=#QtDU1m|NPKA28q zHQt_SY(W1j3`z-|`a{!9X(ST@Jf+{FRNUVD9^FDPEYRX zEB8kG>5GX8{(BsoOxgqD-dRA2R{in|4(u874+H*GX#0bb=fcec>}-dy)`-4}eP z`9Rp{72{sb#iLW7UDXl`rr2|2*Y-8X+!)z`2v_u*4 zV|8_Nt${wuVb1(SN(01zr`}uBAOD(#y&^g4&Fp;8twH;rYyA%D3ou&J+3o11hFLZ8 zW*>b5%2hV!PlEiO?O%-YS@`VK=XZ;~^LBNz)w5pJch!B!?oN!ImH|BT(bLz9hA^Wo zubYxMYKyL!={0up(J-4*pkq;nGM|ie2~=!>dhnZNXm|ZIev8wiC&-mK_KrOL=;Q_4 zczUSa7tB)#3&GQg^hlZEWH1SJRo6kP7XVVvp#9`cp2Nv2qIpXAPTIZ_N?h5e5BpC< z!*^7FZ~tmIH@2O)R(W{%R_hmY6$y9y^AcYAg7YVE0-j!QH2Yje&r@>0YZ52=cClPR zwgFcz7Uc8I?!h zeEWO5d<5Hmn*jO4b9GXE30bX*{R4_pzWH<&%-&PkSKWJRkeDt~~ z-@zrlI^ybSC%vZ0$SE`a(GR=)!OnXOG(pQ7jjbFeOlQf-J_$he2sLN8*CjYy0?kG4 z&M5VSrLn%vytnZZ(~5h&vF{}uaa<2CeLc;yjhaeaX2$+L50^csGf@1=PT=} z09IfJ=Ai4_6KadN+X3)t^SqfY3$T(xd@D(U574yhkaLHsphubfq;cQdW4`geDxeKP zh*~y{{9FKuPWZ+Gml%AK6=X=Kr0JD>FgCd=r|EzZO4OgsxcdCt*-FhZ*@4Vo$rr^s9wNe9W2haV_H=>M~s<}ZC zLr45$WahD3e2&`k0>W%vPHF(?5B7r|VQKX~|F1hlfmx zmV#wpr(#_D(kn{WS4!gLWC{?|ltp{tFpUXs-aO|A4 zk(~l~*2U3S1InDu7Fw;B!n#4@^Y|}wTM}=bGZZO)(33&l2EZfhDZP6VN2#JY8RNNV zheztWyAk!|{Gl;CgaiQOEhdOyz&|8SZkw@&{_O8Ys4c%f#b}vC{(2@H;^D>W zt{W4H4d-vJABBK0{&i1(PNH=1Zm%& zeZw1I^j1P>qb@Q3&jRdOH-X9FZwy}wA>J0+G%YcJcg}&j`_C3oVJIQ^vxpq+h>}Q` z_BjGVsE=s+rSBvNfqwIo=yrCW-Im&;?~RUzWvbtbtBhI><)O=RO*3tftZmK70#g!V z??H=X(F=zbV)#VR)uHUh1k-JhDg)Pw|h>nBeTO%=Bm_I%YWVcz1==g)Z8RX@Z zae3m95Bc|srb)k7_Z=LB+_j&nfLP_pJY*cAsI{ieQKZ0H9=(1hV1>r`+f zl5^|?E(v-^A3S4(;0Mx;>Nk>PTvEb{hqz#SHv9xW6k-1^=-c z)gBkK&&)KYBneoMZw}Xe{pmdV`w8!Sh|eGamIfrDzmYauAE0(+DKMNSt%Po!J6WeF z=O-%$SA4n`4|S?<1hyr4gRwpn*XRQGm)Xg?g-W7d@y7hFwBfTF^xZiHh@9!#533^` zUFs7|%}oznAX(xJMew%62!=mkqeNy99|x!URsUP~&0Rq!XtF_EB}u|vJc~Mi()FJN z)v-{ha}n!1uSfsuhX*^!lc>zFoFImg}?pwigy;zk$x?uGsI?86#rIzz)T zUr!v&GOz0+O{qA1(xQnGBVLeFNO;cvg0qaWVT~L^SKwSuSY*`m!=2-|lJ?C_UWSh$V z7Gto5I>|*hIu=X+5aQLj51M>IEMK2@I723AX94o=*~EzyPjUkm_jeCwywMzH<7R1G zIXqd_8nEa@iQ(k1W z)h(~`vd3S}?;_miid88_vlXrgD051{E}?!U_fIo~%KpGyUleOW{J%2D?mE7V(c}Ay zf?s@TW^YKcbJ-Z7j93z9CPXS4HM@GIO-E2~<{=LQNu0JPW&aRsE@>n!r_4&kmQGSR zo|!=$Wb^tb@}4DR(CrxW;~UfjrzhWe&ZzO%wczZ!&`XkfOYg4Jb2MIohIV4IT_j@i z`}zIZLFA4YxSjg`S{5sCTu?dq%ko&{ByV@(vR@0+b& z%vnS-bCA@HV8j0gOVeZv`&m9AZkd1A;@fsov<6$0SA!p z1id_+;8MFFoTu!0rpPvbG2u=IkA64oD*n%A)@Q>=I`TAH|4TocFewYuL|Ww8m##(f zrH?r<{N_W$i+6_h*PwhdL=njOp=m`%80 za9D=4_qvQh=&%Dc>uCv(RkrRlVUDM{-EOR_7S|GYx(rWKb0uFxD;X!m;7D==Blzi`v4DoB%Zzq21Z6zS z8=(2I3h=rmX*Fe(=Y7%qcZGouIaVvA=yrGTFN5qy1D8~(z6S=(4R8}Ckz*G)ofOI4I`NdMx<=?jV10YijcEo{A z9Gw#_mzpMTA_z{;C0}(0dt0;&WWM=1LwbQ!@VKrsV{7_GLfa)UU>uyxg?N{##%s<- z`V2X<@B&DoZ5cD%lBBwOvL}D|ku;?zJ@T2EjGt8eskS-Yz_+@g!fWc08H`pZJL3t8 zu_Di>D}8$;qsDhF;sJ=}Ft)p4|E?>Aem4u@Ka2mcPgaXO%4515d(Ekiw0u!@lFugd zN@!sS-D?fG=PO{>*Z(NqXpxe?lJBS&DcwITyRC3eGw{5xi;JFkU(d6|szs|q(Ub^R zouSuOep}eK_m=>|k4WWv7@-(BoK`7SYQ)&EZtN~nGfUKX6 zG3z$oPkSv~5yvh}%LNt1R%(I$C%?ODE&UXlxEq~xL-t_q1A^?v3{I%)IYWPVX7my< zQ72$}Oqnpx`u&FAE=Qm)auQ9*(#fnil{6v@685=(InehwtNOFO)9Y@&Z!ysO9jAHa za0xltU-OqFtAFMm^ZU#UH`(`)If!_JKYg|*e_k(+1i5-OWie7(_M1hd1EE&Jsai5_LGvO)Zc=q{E$u0YKGMgteaRrjsRTcq&l-*HY*%?+oCv&}69q6g5A8hxm>oZ6R)`mv7;0^_0ojhR44B>gGs**&4;8u{YcHX(`~k2|zTj6dSov@o6|AU{bN|(jfT;S}%N~ z*qb&gnq5*V=;8(l4T~`7O7xBWu;x7U?&J-OmJ-Qm0nI7wod}+5{Nwl+r{LON?F}-6arRcQxr{t)ng&NgDEsSPwj;CXExXvVRblegV~%Jr)pWa za<7J2vtM9!w_okc1B!7;oh|}e!K=Ie`bqzFQrOVTooDWKl4}axL7bu(^-Hz;?4@zj zB}(mzu8|foJWo!Mw;hB_Ub1`5H#rY}>DnM)Y6*KjUX!3?kP?W!$uyx=0FuiobAnQ^ zF)v1m#u!a9xc-&=rAwh}hnyTN(r)9R(l@yL4=FAq{_$#kuL9UZXFrZs(Dus-y=44N zQCVaYv&vN_$N=u!h<9E4s%P6;HpTge*}|0pI-2x!?6$)_mS!m6V#Ylt>-RC8bOqG=_3e4 z1IVDL{}fsvG8@w%47bK|TlyU5fR=kohmRgLhtm7f{T};|gp*}r> znAy&!>^@5{W5%H60DfJ?F0dxG{nSe2Y3{y1_G(Z>>}=Pe!k&}{|G09|o|0M9#l*o4 zHES9-|+ds%e@s^C+GJx|MRq6d)?c;U0bzPu4RUq zcwdsKl^@XJz&8SOQWblL^JY0^6Zhk^UVhr1@CmwwZ=gjy!@DoquK>y-aK{o14`-4v zM3cZHUQf~$mri+;VsU5jF+5PKM~{q(MHC7fi{j?YIp%93w$B#}3d&oa&atezH-1{KWw>l&8GC9?cyk_vn`?KzH=jVu{ok4D zwySE(V<L_$OOT8`|m5^dR3f=y2H4s5{Jyo-Go!& zm=}>+xX8ytmA0I+BWt-mA`YTv&H?DrU*qX>HNLFil`}qBk(Ar+D{-H9>4m6TX$>Dc zJT~pqAer&E@8d?~tL-zV@z>NR!CxOl(!Ip>#~uGWo1;GwqZ2E_C+dNK^;@-a~rF`hn=oIwID1h}NU05o%Z*1Mcpe>wl;&a#m zXLV21<_do*p~Dn=*uLx2ie+&iZ0Spiudt&awyi{CHz0OKo~+TzFLH4ZBF{jQ3Cwm0 zxspz>1_N`z-sFaX1@#&9ud+5FzwEb_5L<(9fyhzR&f?*%yGgxdWu0p}8$kX?XJ6Qz zqv>oIAKB~4TEwZ!<`;F6W(bdHVF)c^UQd!W+D^tOU-_O2-J!Lgq>H-zn_9&c2J&lZFSBsd^ zf2^*MK4RE8nI-!XdHdss2rN>Cn02;7A>Vv6^@I4=z!9x`tE!2^GJg=h3#Iz^;k@?= z8K2)JLcL?5r$x{A&`$?DXr9ga%&Zpst-#8Ce&n`!D_ptHf4V@8Z*WC<_TMEZ4XdCQGM9;>t7WO zITDWlyCtr+UpW4Uj~}5!_{b0dCuJQzNs$n%fXRW&q|O!#J&2=Z#LKlH2JlO=O(n9w zx7dKDZ{m*x#RTvoy?RHKu?s;!92I~eXptaMk=Bx$__KS^f{Kr?ite5b8^SaPjB|g3 z8bL_ySj;_L;zi|ZynD?vOHr|Bq`I!$D(j^Am!-n)_Dg^0L&3hl>TN-=>v3uefZ30d zQWsS-lNXbqlzse4M0ld-aH28mWgRAR(6syTzgfm-_}I?~4J2uOyYG`J)`v+{@(jTb z-e{8kIaAf5UQOiV6Jv{g%5w+kYFV}Y1;~yQuG&HMVb7LHj%>eLvLCsW|Nd1li|wDO z>l#44iahs)5qsyBTP$S8-1#K4T#V`J!_SAF+|nTGx~f~g>#DQ=mI*S7rAm4BsPWb|K54=jo9 zAmne^gLigKC8m^UHz= zbO(JsC_nc+h=5?mnWg4QvGNAaQyV*FGgM1Qr;d0Y>-#}fAKiuC1rtK%1CZvmOGX z82q-J9Za5-F>O0NLey8Y6udD|DdfBNM#`uJ(b#B}wEMD|^`iJ|Kg?J;H7U*(tkxT+v&L>E>Fsa0Te{C;&wOK?Qx(49QBZ2f_J(6Silug3hTNT(|e% zKk1`~NxvgrIM|Ni$7g)EQ3q%RM%jmpL&4w$dZaWwfhNC!#!sW;cjdP$lXuJt*lTVt z9Y$TxOBOePPHkOz8W_@M40wpE_>xcV6mYM|fe)v`^ertMw=%HUlOwPAV*9cgV)WR@ zVlZI;Kc&jA=b-ojq17)>X!|kN$AhKZdeayCGg1|lBTy;Ip^@Fq@=@ctn<;rz$*puc8R*dP zQ5r&>1U^9c%~_3N31g-7c*9GbK5efc1X)Nf<<$F_;Brwk>$p%%GeHnu4H{(#+m8Sg zzGp8oJ`^*ViQUb~pqUUj`bdTpAW5htU)n)^r_Sx4*8}@QvWodXU}OR-6GJ^2prC>P zt){6oAntsmQTsI#?zLd9A%VKWA@g6z;a8n z_Sc7A!UZkz@BKn+WCZRX@mUI`3)O;*CKD5&lzLtHbh?m*dqHsJ?4yXpv9RdMf2_V_ zy4tJ699yv04=d$zzPqygoRGI3i2pKvd08-*QJbgT|0Uuq^K6UXm5GIfFKL5zZ_PHl z2&42MqezA;CRn z)b*Fn0==^VZ5bN6ZFI{5=f3t0ep?Z1m<|EyuiQolZIfn`wT0U=@7ZM$YFStJ+nv~7 zeE~Po+^madJ{ZqBT=A^K2{?b1J_hP9gO=YZ9Rsp-`E&hc05V7C9vnF8duNy$tSuq! zCAkuc(SfSmB-_4nud}G{G1049nHQbqB0(xr*)TG=(DJ|mw||zE9BH3qoNjx=D(`idPsQL4{#OX~>t08phI6@p96=c0AG6-!kda5;SFDY5vtJK)dA|=> zbz;yVAh(X}<1bOunpbrL=^nKSJ1cG&b}MJ#J5c+o7BO(G^fQ9*AFbBwvZ(rNUb^ks zO7`ceoq0CW3Ix|DHB>Og;LU4x?Uj*K7Pa`X8qSqQVI8&id9qLA3_pDGKvxa#`agLBXJ52FyyZcfl42rYB{S zWU|JSEAuE@U1NWjnW@Xpv1tFS!#2_^-yYCsV|qMBRW4gX)q2%1LbE3Gg9oqtrH&g? z<>){QZ;II|*x#>^fv}AdTd+9}V29ofJlfRp(`d_jzVGE;*!>2&S2U`cAc1y7&F_Wp zhRZi=`TpA#L4IY={W%;cfk*WiViQZvvj#$?i}p-3a6E!RhEo|vH@+S>{7IlTt)2wV z14b0DoH8|9Aw1Z`%NN7r6=1A%pev=?Q{~||A13IyedQ#yC?6(|ABLQ6B`7C}b%tjR~n96Esf?iLz)blf@3X;O5;P$x^k-x-2Qw>)FU%j zmJS?!x1%WvqoTMQCR?IHc6Na)m%UFS+DDH-pw$G32Ps~lijtcbUA+<%+|iFe|9-W6 zkiyy(do?CgR(8q9!dI#VMunYO%*=$+gqp{FyImUj#Z9B|4^32&|Kv=j#^Su1JICK2 zu4TA|&=Jr?ddy4kRJY5Qg}jURyT!>X_17UAxgVeWH<_|#FH}g1SF-^p-KVQivA|9q z)7whxVj13>Bjeyz1)(B=6Um1f7?_md6uRAF~aMJ zxp8s)F;BG3Px2uXRhXYS#Xm*HS3hEA*CxxzxJcWg;_#*5!7ei-cJR@}UOSV-o}2LlE@O$4X20If?Sg;p?1S5etvYuHe}n0Czaa!QK`*$qZP=_N)%l`oZig=nR3*5dqfH~oRodFU(x}b z{@i)sIvk^=iTU$m7HqncP zF(h64ba`-FcH@5qQOHS95q`D~W$+9pg-1np(KH>yARzDe7H1dwXBI&g9_|t&c9X6y|3bjm@*bo@1v#y%@`;m_feRBr5YX*O87sqd#k{0H$4ov8b2Z3Drq9Px5pJ#mbEHy;#e*eUu5_LS z-1kTK;UzJhF9`IFO~e6e;<^(IgXKQ{ZOuQOTzHsr?w>y?e4BNlBDGCgxiu@qr}LXu zvx=+6NJZ3^l5%nYvuLL_oe$9v^}*#s`c%UAu^ciZ^@aSRv$V=@X~q}XuTUv;HoOiE zu%oixe&;-U+LIBp^>n4(=+eg5W#Pd)W)1o|yiLDsuNK`@%^lYgDf#W$@8wFd+xM*S zm1D0H=v_kx1(P4*+W*Dh(KZYmcJn%9C-Sdhed>b-hLN@ptq+BT$YR56W-41CrMEpN zZE`-+D_qFsM16a6_2Q!I_OTy~9Ost-bDDJ++S$`KHFH zR(_n3fd6@RsD(Dyx8GAFz3RVNp84w97krEycG*N2vRzs4Z4>q%5!i_;+snMcIsDZy z!%ak|1_u-nXh%m6itmx1YL>*XBEJHJ!$}-eyU{1!|X8Eg}~Df&#vuT(jX$@4lU`FB3^(qcN$x_YqihqXYa?0F=nLq%AH4|qx5m;eJ$stGa2V4h;1g}uG%l5cQ89gf1I9+bJht5fs0ZsNClw6E53-G z1CXZ)%%>CO;}mBzmJLp{M#fPSFa1^a49qT?JUDs8q8_Mj*+<#Fqr)iJ5uQX~C1670 zpOE8D2WFs;XT*_#uU;pCd&;=o1rM!7)J+#`n8 z4v<3{tKt{|7S$#E7^ZxgpJe#HVqF(q za(I)&n3i90obdemM#fM0E>F?k8xpTX#iMp%0rLY17p;g=Doby(vt@FaEOy3##8^NdPB=ingH_Id5KQSfk zTO-9C31_iCP3N8tiK598XY`FD>0V{O`ZrfFkd5OqnyE8EHuV@M0@fT9>L z3oLC(+Vod)b8=1N%cyp6avaAF_Uwq=;r;E0JVD@m)o-pk2{5aQlu|#7{EQpB4a*Uo z?V}`TvYGh$bv{=>kmkeq^Ib?h*QTr;qJ7!vFM+=VRAvhz-eho4vXb=196ir}kfeu^ zrBLU>?~&A>MkWX&gplWl355hw3iEh_+`YX!%c7s8*x?~dtBBMg_SQT2laX6C(R4%C zK^UM%d-22med|ovl~kjeRARM*AjyL@FE1`&pQ+jmc%~b;{FU7Y_;9~aXAD7%qLZir zhwg>RHWc@cM$>#QsROdD9-gg1}@owyQ!rq?ySJBB=wR_{97Wn-1kfk{mHBuLqxI3g1+3_RXi&zFI&GxT%JmNi3 zyeh9HFJ=Ec!Y0!I8^jz7v0(Y5vg7UXJi?X^&5akC(oQVFKF`0ozULWU{{YI`@nsbO z)x%8)qwGX3IX>R!7$3&pb7GN1md{16ycrC|^HP>;N5IX4H_~61zjtmY3(!j+H zho85YZDcVzpixj8O4(V>;n+)WEJMx(y*rE!f5(4-&m+v{L}ZxkxsTC^t=qcb$&@Hk zBn@CM%|X5=nPHs3Fw{qO+zKQF+a@C7@x=sJQ;hn(-(AYM!w!3|;L_Z}*Vg(|W5i%T zz78KNAjWrGgMKPEV1t-|Ye~r9_ujHE&>4XLxpl*IALD!%8g9Gt>IUiEXFws~e`_~t z5$D7=7w^P;GXzh8Zj*M2jFm$*{)W7Kg{QalJ38Si<^X~i@C2KgOH za3xYO0dRPS$W*zzdUs?&+s76xjI5FOtk}Y4!fSOxmYwqd%e#4%E`_QVr$=(Thn(#Bs~Wdi~4?}d(+i_^P z++sP_0L+@Tiy{v67|8t7n(I;N>wBlQVBVuQ3_D?*vN1AjqQz4tJYiUsrjdc~gI_HD z5Yig7w`5dPN>+=Hi3#q}Z%3_s@bRED%V|j;$Ia+--~g`{v_mN>Dg6D9!En(E zLEl+NKNRgsdG#V3z3c@25+Q9@*fsPP8Q?=#Kg>F9-@8~4EF$B6#sK_=S9<;^`kC-V zFTIZRQA4(i(#e0s(`YAj#6=>eC=MN|=9(Rv@)h}drxU}^mQ&R!yb^)d3AV**lk(O0 zDfRdz4yRKr3#Db2nuTSS=4lCCLylB6uQsi9rg||F<=FTK8?~u6o*gl>+#1a*Zxnp% za5Ona1t>>8%7ZhM10%a%VdJYt7bDugvN;`Bi#NMs{3K{ndN=b`JH{vfsom;Q%{h3o z&v8AY!bkXJoc%O*1Dj{O7@%*Zz{(M%akkSSywlE(Hs_-(QQ$} z#e6<9$qd)Gqj?BqF?(KfM-A(_iwE&&*Z9$I0<4AK{@dbIvRX?xudHZ{y(DXfAIYig zqRt@~Js81z?Px|((}v#1@P?b$;%!u7E|cAJ94y_H?bfp;@>kQKF~l}Cf)7L-%5i9X zd`34z6K1ajl-mG*8wYf=b{Fa zl}}i#w)b&IesokD3(ga*v}~aV(F^-BS(FSFS6jkxUWg6LSF6*Nj!aPFJblws+{SoK z{nKUP-?j53?TDY7ew~c+)7(lP@9K#4y7q)rq+J(Z7fJ-yJFbX)9>N=jz1lKw)#Qu9 zHpIU?9fv86jI3e;g1^KLd(rY0_Hilyu3a{o#Xtxd*UJj-N}!xDAt=i(k_&y?prDE1~Hi|4jfOeG`mg&^_0KLpDDecyUOqu4l`(zP-d2&sovlCgnIR?{@u zC-=)E_Fx$>`bVUq(Y_A!690m;wdml3wxJIu2QhUfwbgI)(9(JXy2XL>v4I5p$SYpt zlD!qL%0cQhtrlCz06m4(W){{>A=xXk9$rvw!AAFvbG<0*PI`=l_UYeWX2;r2wmQA~ zSt3_&E{}T0%N(y5OWMwL=UyLA-QUbH`Fum{U?jIjSYJbambhvg10o*+$lQlpt=@9w+LQ})1BtoIOW$~Sq$IUTwk)TmPz|Z7#&+z z-r|sID?PX;ZQnnaKpY_P#FU1I-zJ-X;SktP>(b>lkFEX8AB4U)I}^5o4@KE;De86i3-1!u~)L1-Smk+u?=y21NxUDR3TG1wZ)EAbP@Z z4WJ8bj63c*#J*I}T!$Gg#FAkRgct7PApr!J`=q;=h4Z5U{eG2dB&q^rLrvo2QNY|t z$V~JxjsST419tzkfh)}wA z%^$$TUrVWY2<0yW`JJBZ8y9^HDuTD^Z{b8wN3}exx4m1yd10p|#g)?_KCKmGWo4KcG`A~LlN%w4hGiSo7bJJ)0>lWuT|Gk4^3 z)&eyPZ9~9v0McHZu!9V66h~dB_Vq^ay_f^<;|ZYo2h!j3Q^fAw_9gOZ8<>z=zqPks%7`?d^cZWRs!63CtMxC+4t^cVTeGkX9t|eyaKeuFU;u26#cxoW%%pVK&}p8 zyaNgQmylau?<0-}a`X#e{xysYe}+CihWKm+w6>O^KhpZQEgM9uOc;><8JgXF{PCxF zI!I^zyMF*1{MfZ?*NJ_59ttPmwgmALp`!Y*X4;dV=ovxPXy5rKr8 z@TZ#qO&TmTg`JuF#C^y=WtskdW8je!1B6A6RIvNV<9|&9V+?}a%m9ZNX#5Uj^O6T& zw}c%vssgar3m!YD-18mCG)Ep;|34l(s6&6>f=GVyXhjKzIT0I0s;y^Rla@Xy_x+6h z&ocm|%&wfsM3rsZ!}PwC3icR)|M%Rxw>TZ_J?GAy2fO;cV4DA@IN_AOVE20o$PBRw zxD?g^mqNnjHNfcaMUU5=cisnUiN&Dz2dKXtx?oPH+Zzb7q5Tf{2PEY65uJDI2`36@ zIDjaZ0@+~u&ig?F0P6}kA+=W1NyH%lwiZ`jbv96r#?gu z*x+gQ8?8Q_MF3mbnI8?y9RD)5pgQ|$BNvqW;|2Ba1A*^n(%5TPy`|bo4+4x9*1t!^ z@>MACvw>_jd+wXiWJxAFhwgEruWr_fu-+ru>4IEu3wEs+pp$m}^mzd{GQW_o{dito zJWT^ZWWq)=2=a-6##-6=yL^4Z0++b&f0{oh?DAT6S(*m{aS5PK2DD^9-LTNtHS+eV zwdNo0`_yT#lHBjv5fjA=FT4!qR>#uoxebO>>Ecffb&*%d6UMxUGxKwGQlAI!=QzZ? zpx-}70(fI#wfaeq@t<%$yLkX{8wy0lKe*$U3I>-9Xv2RJ)QI1E?_;F-AN0g|kk^!nARFTuM{-KQbYBl};TNwZRe?$S^A3bIq>7Ft8gzx+%5}+CY zCKHs3fKqYo^|!+ttd4j9*Z|C)odI}d7!@gt0=NbWP7}aLcMb}`r*yUk2+t3w^i%-6 z2#1jMxN9E^`q6){kU0hx^7|LBbaeeiEUC}k*|X*&o^lE_zkS7{g zIqJxZ*spWdb?A1~_wwtQ{2LMdyC|zu*NG&I>4)6c#pLZFEU08dl$@vi^VJR=tzfe$ zOov;}mMaTd9sVPYXYg^z+OZ9g(oRKBAy`$c2X^m0VCvIT+yh<}PdqgQhtU>ap|3(M?w@pujStCdXz(&I<$oQ7MELxY} z4&TYwuoXc%?GXmq`#WXotAOmiKn!;*{>T2~bXEL| ziS#EAx&P0hSl^NWOW$|vGX*|B>1glWl>pzlyY{=rp)aH{$nk@HSmIwcGxM=UK4yr3 z+4)H~_Ltw!{G=fT%IP0;|HqsxMvNFQ9)J89s!z|r^nb%mcfx%sqcA%;cv|S+kiFsS zxJCnT6#&!!C!c1-ZQXj#>eXvRkNpmTf4VIt1WFX3gUt#kYwt7wcsar* z9nl>S;QyKE4hTGe$zTrjDv$$@!Z<~=2-aMHIFAYrbL{gx{k^8aPXDQ(TcGa+prCNG zdsl@q>S_D!TELa`6|#mv8w5GTzE~<;_7_W8-G02nX*zUduU!-9+qUd~qZVKiKVuIZ zwdOyOSUJ+xlzY0Oq+)2V%Lh(*N6Q>$h{JBTwILuCs>9xXYkfIF={eGQu7HKbL=>6MYSEPe4X}F)qosr_Jn*12 zLHgp0Z(!0tpGbNmMvW7fTyhz3RE`#BoPC)r{y6#fxf~FI%R#nw4R8W35fM_Pq(Gwg z1!M&bg|%20AP$`*@cr=%WW}Fw>P6zv-lvL#4m$}@fOEvdj|>+F9daB6)3kvTDo$E$ zl(niP0Ls+_%02P_nf~$|Ko)+c5FnJS`31j*O{E-S4k7?SWPqF1xM_0@4s2~blne)7ImkKQ-+XSilXM7n4sXEG z<})3>K?H(ofE;ff;5Gws!~DYWt2$G1*Cm%;2QSVAWDShR%$aG}|BhCB{I&r-zaRSJ zPQwJOm)vxbOx-Px~fRh{Xb1u zUc1<}&hd+Tp()U<6g-bR_m(*kX?~L}58Q>fqOT)li`$ZZ$2JQs)pZf}$LqArcAE$6 z_H&Ktj0gF%*jb$Z3n*}=n2?NCt+T}&Z@fuw(it=6Ks4`u;=~irfT`{cap>Vki^GpP zohJU%&bSN;{}n*|lgEX9Z-DsNv*|v!Yu5otZ7YE7e+9^3cL7P|HFSG*4e!wY|EOcn zq8x$aPrOh(IRp$uAYvGahp3gu;@>7uo92{A1pqv+xquLqlNr{Fa>uyl({!)mQUR-@}Zjuem@(HF_IxBrd=mRo23x+2hh`eCZzcD z|I_}A5dnF+Ukx|>QAZ}|@JE3~;FBRgbrO@d=0Y5h+>>q7EnXp_$xebsbR@#hje zGyNyrui50D%VGO`Ho5a@+cDws;r(K^pD$+nebrRtxo&+HMTncIU%m4ueo%hgpaem4 zprpBP+V$_0Q_lf1B)I-T;eQf6|DPR8$TdR$IK*m6xF)U_dEs-~p%%}r@5#`gueNO3 zvSZeN(g(P;U9xuFI?zaK6rhu70=QLhvaG#A05B&&URJg9?qb}y7sYh&20(M57bd+7 z1{CwD3vCVB;lL`uX#gsm;GIIxz{h&?Dn4=1^*jENT z1i0URp#%Acc=7Y^V=2d~*fs!_UL(Q%1yX@%Uv4AJng5Yk7$K0M1I%i;Naqodc}s~S z&(UwzJO`F*E?vvM8FegYw)$Sn^z0F%pBV{X$nUi=zbCcG_=cv5gmU6Fye|W1@{4$o zjBW^Yl3WQzAh*me5WDWWJ7~;TQo%+=!P)0YH^48y{u)^Ndx|s8xl){S!S&+&zBh^s z`rRxpyyO;f@umI6rB~b`F2C}2nEIa-V@8h?FMt%WSFfXJ|37%}Fv%x?7~x(A9xqNf z`2uJrP7xPfd=sn{#=>+^1>rvtIr%I8LYw@Ux$h`5MMPkay?TP9-4C$;{{{$lpOVhq z>c*`Kpt*u&+yR;FE#nv1G>6?iqFSaQpo=GS8qbQhCWN%+PNKRoa3OJLWsz}Zi5YH2 zRO|Z@G?)e;?Q;a2Cde z{iyi2))v1S$uo@qVl(MmKY#YM(&guQ1V6o#=k~hp&yr5@&B-qgCj159ez)+48vU@z zKAZpjJdFT&mcdM6&<2K`GN@650P-IU)BoThX!|Q00Q9mU@*jr&_|kK20HpF=P}YtE zO{V={5M?u$x*U=HZx=hNboy^5(2w*KM*(pedzZee)tK|t=j>>8Q2F-w)5$-yMaV>Sntzdeb5h3fIeV; z^Ekx^x5<`LMZM;rhDxR2;kp7(B$z#Z3YZWf{QvRCUl9QirEs<|);obQVXl4Yj2|rH z66i9%Gh8|dOE(`5WX)-OshvYe<0dx#4&Ix{jW@pkGrT#fmeD%)y^e>!(*5VVI)7)k&zShR4(q?zK?HL7 z*V-1?k>=S(AbgUK2PJ*^O!Lo8feL&cfV_P?m*9Ku{0lCn-9I(~Pd+sqQ~=|}Wmnt) zw^c&LUo!i-8*Np<#14Os=i;~DewU0z!S=Uh)_>whQ`5Ff*Q{9!u1JRxbWaw+6;=&^ z#h(tX3^10#@_UqqO@Dc=m^gU`kWi)rQE0xn@S-aLT8@!-^8b|T$0c@-zCYpW=|eLD z^zX!YF-`@Y0X3v=EOmnt!+5w^QmI^s}h?tbX52 z|31^7ANoG4hCqfO?Mx@X=SGcD_P^gjaMg|Ci;wF;tn-*y_~@l#;Io57zxyv0=k~uu z+`@%@f7U;h0i1IV{g3q4lqetPTKl~ppDn$ zEvIPu({efpb!T{HKqk0PcT(YM>exD)u;uIB~_sq^Qn=&;P0+3U7`A(!6wCNa1IXZ`$?#&oC1p;|`eiZGIa(l!5Ma@4VqfTtSBR{V)w!g*DB<$EGDlWR{a^-)q`NFLK)Q?S@2?h9J?6?=; zi7GX|0JH)qYfnKlNooSx8!iB$0N@LNy7QxeX^A@NiIb*_DS!fCS2`J10_{6@qZ7B@ zvFHca7Rm~b#UC``@hyR!On`V$4N%CFE{&+>;z6JaM;;P6;T@s;@67tKU6|T)NM+Ho=FK30^em2)B z;%I^Td9>7AmtT)yd>VhiB;SG5Cgv&E}sAXMe(cnQ%udQ7LSizCVp7DNxc4Ry?9{J zAhCGWPhvILCcpR2N8niZ9zgcr(Cwfp&{-fKJo&VKWEym4pUcGPvD4wii%jcW*#P9o zw7deHjHhC8F%b`&lcCTcll3hi02sULSkZa~-i44^G94>WY4&^0K00I~)r3H2FYZj3 z-kv>#Xg+kf^Y=Z2^mj` z;P@`M;Yjot^Ys8XWODoq?EPuhXvop~bM`*>1E{k<^ZpOLdchdu8M87J>htvQao}({ z6&iubqGzwesKIoKzaj6&4B3y%@SI(|s(n>Hk6q z^vcdFCd*&R`fuxm*7+uMx*7QJgOB9&zvVx4`qv*@w{5$8{f718kVB6l$3OA~C~t3y zK0oMkwF)SOCIAcF!vmgz7il$wiAWS+7QA3@1*=8e@oJ0UfSgZD?tErHOW7+5^#urO z^9y4^2R;Doe>zfpy8Ih4Xv`8ZWa7`{UN`vT=f&N1Pl(0KmVlGpYO#9ldMf-BWegp~FTbpS z9pWe;5MBZ+fQv=nem8*C>Puh&ycd~_pu&QZ|KKE>U!>swNQsKbO_D?soie}vW-%cE z^F1>kzSHL(^LEl~49L?m9^0Q=|2pe|v^BlSgu(Fv1wo^c#?@pRLgjZRU}(N>P8v)~ zVXgMig$r!`5X4UAHKu%{+zAPeftA?|3Io=B7h`h2a0&YX#n`jL4NHa z1bmgC`~k4|w`i3IR^SJKq1kvbaL{mg-;N|V#DPx^6RCDx=sX)|c{Milr_zD{HS_rc@()M{T{&JfkqS{6aPC2v{LpIqivm!4 zkdzlhEIf8UYXAykuG*o{TOU{-gy1T`PW!9@(Srx5fJU3F7xr{lp)i)QQI$W{5Lhx>k(-bh4Py@P)YI{?+2XXMYs$ zeE*R+cgAC)e|k6+{x#y;#zyh!o3Dw6x_YoJmPo=!AAbp^LNAJ&`#&I#2DQL-H{1uT z;^*Lg6vIg(U&$S#X7`UC8KeS$`;O{}b~|+@;@{_AdpS56UD*IMGEaqiPhMH?*)m%>W*A{JtYm4`$NFmcNl2=Cjz>9`<)L;-V22L;N;Jxv_$yRE`KJfH|Z2|v*Y(| zfD00g*`Ev{`f($lM7TR3TPxV2@2UrkzrXMFrwe~Rv-$OYW=Mc};Ya{V2k(T(=c&QZ zi9v&;>~GYl2|(UG2Z)eKHvLxx;(n$7zxJ--R2 zp5u-`T_pu}b{c@X>q`2V zF%RGCM+1(5F5DkYc!e`j>3H@x=QH503Mx$0uMHbYIp#hZ=8y0+zF1IU5Ku6%h+Y?6 zsAS20y5Xn{Q4Uz&04w)P8ABl60QhjfY!qA)BbN+DIP7nb08XqcXNEhPAHYL+%Do9) z{mhUC%U$W7PbiF)vz0=rg9sEDq@kQv0=fav907gEHGo0@;0=uqT{C9Ppkn>{!i8Xb zln@OIUK8tU9u;fm+$DZqvs`q3;v(_%>(7fh@7IZ`uYpHE|8?TgiQrei^aF9^xckI? z=?P-PuS>)OzUApxNU+cT$eCfk4{ zEhI|U=ND*)K?b?&hM>0AjmdS*b{ajWLRYPjVF5ouB3(oHZa(8RESaFaZ`dlJhBZRf zAq2;C+9h2&c>&Be*dh!}#;5nDGMAKr03_4_igI5c~JBxil-pd zXT+$<5Y;>lWUKukZz4jkd8zZao*u1oq@qSn^2J7P@51?skhDJ)2h44M*$!X-ey+C$ z$Z-<=5*{j$2r3C!1K=0}dCdN7uK72rqL}gqNII~8wptG$`iAc{3&Y#eC+hmzm_ z;33b6yYGJtjJkmF&%FL^(XUi?zg5!mH(5noH}w3!>89KD^nYg7f6K>~En9Yg|M|l= z7uL%)fNB7W%Xc;nfVKrTd=iW zkVtzKE`vW=8G!tY91no7Y=A1)YS)A3gT_6Tw0q|gI|K1YD*~iD4ZBCogzduq0o8$K z@Ow>)@dHq+0HZKSR0W$8e=WiM01T8LO*u_Kk(uaI-k#s(4A{USMi`8iho}M+u@J7S zNrKvxh?5Pu{Lm8gf`mecTo^5N;eAu=x#5ph1CS(;D+N@M#s4Q4eunnHi+_^#zYh)= z04a6{h)pY(h~-mm5ZgYUDrW!mzSwI_Kk?J5C1PYvM!fU&TG9R172>70mx~X7{Y0EO z=Sfk&@*A=K)sL*r%V-l?71HhipBx)Z>%^QOoIjt91UE6 z^T~Lqb&m8hkd~umiu71Y{sfW$*h*Y{$(2L^{1}h~j3=hVJqn+ZP;#wl1Qr=6-9pff zuF;Qf5WE7;iz+wU$#|2yD3Q{Q*x!NC3p|n_)Asu-fIJrY%U1w7`TqW&xdrB1Qy?i3EH<{i*$Up*34cBk zki(?ITn)exf?UPHK{Wc&$DfA6|BQI_v8Tl&k3J2L-}4areFXf?66yI*UjNpghOYv! z_$O52UlpKsm;eeZz>l_U-Lga0f71uB9Z$lOY2SWVzyn;ORsl8>AjwF9niz=CN@-_&yjkNYi3q(V)Cj3bkm17K~1tGu*6SASm= z;{ER$_f$A$KDN-7JJWO_bJ;5jGaHK8bU@v#6ecQ~F(FdTfbl-C=s}^e1NL16Q_Kpu z+`0pDtKfLb^(csWX@Y#M5Up@L130T*!ttCJz@~gw{B=HoF8YNW8Bo82MO*)VL9`hZ zAs`iG(!fIA1eB`%x;^l*Kp6DoQ%)25Said5$=DuP)+3WdQWa zXJ685`|>Mp5l;@CAa1y2fO!1L@!%?WC}k;VVU1pg3f>T zYvu<~tGx}vLx%M8Q|ld3Qz?uBL;GY07kol4jDggHzr~eE3UJgyuI4Jx>#G{W6rQQEk*WL<$ zrv$QTcCwScvi`Lm04nSUKI5?463~?a10Gk?|K?+}{u@8wf3N@elh2d`UE_%^dw4IpbiO2slXtQ_-Cg4V3FrMfGFRe z$GufT+)$bcpV7Yc zX4VCfsLS*>*C{OZT>r|XLcsA0C?UvC+y6fF&?7Sbcg3$_=Z?FI+0~%k|FKalpZ5@G zcUFs&X5K9(el}Okd+S|zQGYIO9{IEAb>A0a&E`MFBMno;%?n10_1iXx)x+)(YwzwS z_SvaQj2tt8Zgcp^v7)M7S8?Rgr-}iCo)`B$1W}?-JP)YDE!{^1bjGZBb>PXT;=;2}qg7CYpU zYbF(cW2Idr$$mLeSy-vaS{OesK>AArnfvBv@sP_HagPP-SbN}v=kcFlQ+_g#B*-}d zCh<=-0J=E{E&j}KOsfELi9?_~3E;jV52wp+Js@p#G)?~lA?@!m@cl>Y-;TTVpmRr} zq@7dvrTwq{nx=oa4*J@3+p{Ocl&?WT@xrYC)(_}>4#5*(|J&{YEq{Wz0JQ%%O~B5? zluT-GfHKVm=tu?T;6sn2q(CwU0%_p*@zdZ$IFAT{_@u`bA<(A(xv;;P_~WdQd*ngW z7i= zu>W0UfYShQf*&UfHgiN+#Bc!4_q+nEr+}M55V{J`e1T}WLm0HKgk^4{5##8CRUSk_ zda73(?ks^N9I6Ep(L#fF7r#oD(fiN$}c5c|JysrYL3 z_hQuC8u9*@KZ%|yjZN-yi!~`?Saw`mQ4d^4c5ZxdY06Z#rL z)B%bI5W4ekFa*b-B(uTO^>Zsw;Z9RE1PuStE$V1c)dLMmV=6fg;jr$TIj zQ#RlymU4`8bZVc5+pQyCd4Nst!1J+Y41U-ouVstQ6wive)1s`-eOd#-+L72x;=DDt0P z3(yLnShR-S`_AnvG1%BZ!c~X=S;gz)c*L6SoZ~}25vr6OuzUv(WP5=v1-+7v3|ow$PYL{ z?6dD-;+*rY6u19tAZV~(6c=1{4axs<;WdsK3&{A_E{SF(0?#vA1n=H$Z}BOF|9k@a z|99W}R29udYTwU}*A^KGUG4lup-q5k1Zus>wbtD!XVU+Vy*GiAtE|$-NkWpYs(Wkg zTDwC+5_V*lML;%nKok@aaRtN$al;)I1r(4aBq3y9$wCNW4Iq-F6F?vd;4(AfuH%a9 z=zKae5SH-%&w1~=Jm-CHGxML%`No;){{8N+tE;Q4s=KQ1Ip;ag@`ykO(0vY#Yf!81 zn*n?rC~8A~=Z?t0kYW4_6l0VBEYSkYFNh_7-pJ@-iC$~~Vx3q8$n}7n%FPO4Xp7L? zZ`cr$D)Vv$vY#Cira++`acoYsF?c;hgRv0`KYhOK_6_7EajPczaq(xUe=RAfE+N9& z-uoV+(<4WZPcvsf-uV|zXjd^y$>aiwK|Tdx>1&I0f7Ar{A}#m)&KL+h0*15mbida{&AOpLFsYC<1x8y6VbVY8ny$ zX3*q+8M%43X&BYAKezWc>3+ufFEap|=mLNEeouJSYZMXT6>;I#?DW6=WBW7D>_O+% zXJ^k{pb31;1!#6a1z=VIJ}uBU1ey>yl?j1sv@DQ00Lz!J)e3>PpY>kav()seAa)2G z6t=%LL-MORwZJasxE5ief?(j^s@)ns6=$_-lhP(%S z03`2i$a4nV61eX{Gc**S2#(P&)e@o1P$Cywa&ibB$(`^WdSZXijo!g;^Zk$NuW!A5 zJ$Xp1rABh4x_<5|?I*!|e}=1%KjAeLXq9G`zGmvrH2qam{Ik7(pV@os?2NSKt$oYc z?!T1gO#gX)07-w~=GStEBmZI159NM24uHPN2-5oWeYFB>;ZPmmC4PM-SWD-Gl+XmI z?#zTZtZbJ_d(o<75#@4_?Bp>FX20nMdI|%yyKO+GAcjGDPedk|5THWufpZ??* z^jk z{mK*7F)uq<-MDtCditvy)tXaJQ4?ETb=9=#>Zd>Znfl_FrfH|kH^1#Xb@|l`)kT-g zAa1}~wf9R7RHMexiI`4kY{KH@k3HA4)J#6X1++r=@sFP*`2Pv@@FU;QMPk$DM_tiG zNIfj<8#e{yxgj*$L4E#KSL{PWA!`XNvgUI<^f%@i{a{wX5IPcWbHzO?`db}oke{1g zWdL!`{|=8tsf2d2M~FP~1sEoA5TSPGb~prlhXBIgIP{1}7+fF7_BO)$!>JhX_1Tz5 z>;s3uKviM?CSSSltF+_)igxe2e$EOi{6z3uc8d=4xsLYMr@a2ndZOlrR;TuvsXt5O z^o2hQ^zqYo$xZ7mH@z3jfi55If3Xn=VteBT03~~^0QPSQ1%cofz5^HBmrEa-v7Z*>I6>b_wS0n^!%@Z z>CYHisl4;t_hoUvPjA0;r1D!?;B(K_>2LCf&G&y*7ebvBXk~y{^lesvJF?hdE{4p4 zzV)_yGy`I~$?D~PUmk0Mpgg|ExJQ(=|lxtzL zFc4=5h)7}UIp8OGffj&*pi-C4f8u^?1Ci5UIrI<+WrJWGgpB~-hX2N+b}mw5`ZwAo zZPm)#)Y<2}SM4!r7d8L>kseXM%>n_sUc?7ycv;0-6J{f|Fh9dztzYH-h2syz;Q ztLp6a2GzgI(Q2>V4^yAJa+-Q#&g>s*Sve%E=QP+$1c zT$=tVO^mEqEA=)p|1+O;p48GX>!6lJH1i}kXWkO^!yo;e8h~$W3t(MP7y;O9i_A9X z|L=DEZr8pIh-h12MX*&|zab#BvD@B|b>YL%AD1)#NN%8wgsiuS;=fT8|7}9rHx%sp z{qqYn_Te!!Tmuk#0X!0={$T>&h9K9#at4NmtW@z4a1jiGcAybJXTc$-%#fx`8z2{8 z{e~bgNUsgJ0eV#b4ez>pvs!i2U25UtHCoQc1iz)rZ_xz5=`$CqQ%`#<{m=LGo}Zm0 z3%c-E1QNOchWr=^Yu3tc?-2pVH3>e~{ z_xG=1;$O=*0DLa#4Nf8J?;mje|0S&eN+XpYKAwL1=}+j86q>dQq^Hz50o|#Z2hi?$ zRY0qg5d*X6;K{lH;9_y`VdM~a>pe96ucap77PWZsYWn(Kb=v7~Cn8HjHw<tW0lNZoZ@=sUUz zkSIXcp%J{}Z1i_%y>u5jP~NNF^452&PJdI~GwX77;}<@yZeM+qI`jSSQRiNKq59Yr zSF2B6wM4z|B0535{FCa^g`3r}=U$_BKjL)tdI|>m(ceF=etp4P)UHG-eCctoQTN`r zMIT?Lg88XWU#Y%$1!;=s-KZ}A(sXJ(`t(VeUH%vW5b?!E`lykLazm$Ne)oI-Kn{T4 z$)JBt_Va|lAz;gU2EL#AvlTZS zAU6~)fB`q458VFTPQT=3eO~e>qsA={fd4kNMvtuLXK1Tm=9NvS>3_+RRg{W&HZ|r= zNBr{xrHB3zRc4F&QDpja`Q=v|+27M|8>#rg$MerUH%{$P-}~9mey*nOet>pgYxj2Z zvOrt-Au-Ti?vgh^k)Zx}(Ij;{sQ{J|0l4e?qOj{eGGVPE=#Zj#D4{zz5Vlx7?-wp|?H$4fVYr{!snq*T11v!4D|W zYrDFA-2>_mfBb{`+wc8Utz9>yZn)_-^~*p0MyW5pTU~zSK58d|_h(+Wl+dc%)z)o) zPrl--)Ww%fr`G=R0RkoqBd0v5zV6|m9#ym0=LPJcodI zvS#4RWZHA*of`?&yi4!)=TO1FXK_Hn001BWNklwSOI?d)v>V2ya3gP+s(%Sc{l z)Z>bL8z6ts_FH@3vPS?)4vFw~!-5nDfj~$IMCiZI?EDKQ3H@XEk3Nc41(CTN?nJW4 zv(9;+T1dGF)26Z9Zyp(SEmoIYdL>QvlYG#Ri1+meS^)ENKk%R<)SoB`mnQ$MJ9g|C zHB#~OQGi$R33cxL#q@@4YFVHo0J;@`Sq%(WQs87m3}nB6Db%Sg)ENQ123SH#R=f)M z+~=>*5OU_x?5BU(y?J?EF8rSp#anAki2VL*{euBlz&i)l`a>e&pi>1@>qz(u68!=` zfKLLHHoR7=k7hnTYrx#LhQ9wX0T7{d2hG<8;s{`umY0Vb^oCteRC17l2njHs(`qlL zkV|L4GAICqX8;lfLu+AY2iyrPg*%dIPmjU>%v@-9r%;qz?|MjGHDek5eob}YfrqIp zE}x^8EWSyffLGpln?|74uDwg$O^KBoH$JFVuD+EfwL8?EWL3SMru5rxU8mMva*?|D zEpMcg$OQF@SG<}E{(ZX0ZKmU*&s{~1miLn%z^U4hrN9UP*#O&2bsGf4fh6pqFq-lS zjydjC6ixgKodEdQqZubt!p>0+`9)rv7aZ z=0?&HQAPv%Nq)VHBYq@%&3UT7pV5x&xw4%wNAcc44ddEdzoJ$VjL!vs9u<0q_UBTQ zxQOhcSKfFR?e_1{Nqy|uf8t56qjTUyde4m2E1|JG(NdutUqVBls)PQ=8*O_DI7DjC?FAP=?Rx@WepoWQ9aP`~J2~fow+N+hRjtiG*&VPcmbmf;05- z-|MrszX@Ch9{?r;^8M{JrcWJs@XORZYO1CY^l#Jt=FVTH4m{-LT7ugOrhlD4+m!2N zzK&M}OtQRj4)4h<=C)$VlN~OnDr%J|Ikd+}@sxqO~FxcD(~)^7otoUwETw zzA*^gfDnD~k5_G=f?zP<0{HMC0o`Lm0H_2?2`eGn-y#AiHLUg`RKcPFYy_nF01!H4 z$(S|j_{WYbsVlFZt8QKQpnCWDpHQFt^c6(7x>e1ezgpX=E?rKSX?MT+CR#($RJMeW zr{x66&YHc9;Mp~rjXiJSEo$1_o7FUu`OTWUQgu2#^|{Y|N!?D<-QRudhqPNdx|b^EJzlX)kx&zGxDeBu&y;)$JgXmJ{;J#aTQrG1FB0u@(H6x&kHue1rMymh@}rSYzhK)Jfjn49 zT4LW{GLU|u+zclCmRuK@=Y}c-Pfi4_zxC}0l|bn5=fZtT8F{b|n*($3{~MnNF8$&( zHIs%(F8s{=Ur4dPtSr--|L$ZtJOToMxiih~uG;bMEpI*B5dWTDK2qWT?VNxq{I}Y+ z?QtUSP11#3a{+?HK#c@+ric{*^8xsUedoJANRt@t$d;_uO#u4?tXOfg+H0Se>f-IQ z0y3vSJK0yzf4?52`~h@dYYawS{gtqwi`#qUK`n^ijU52}gfD&e2lJMAxrU=bisi_36)DsV@G)G}86ITZj9capqa-`gu2yeDE{G^1nt+yKb5K(pB?G z0WeElJ&kh27vHSD`ta9vahNlEu_`6CEP9pp|9s-Mw!|s$0BXj-<%^eHLk+-hH3IMu z-G?B^ZSuxCRyya2egSa~rdY8*H~arhw)k7a9fJ}?UjIX}VGc6wLrwjV^W2x71^WIs z%Kf+tH*&JZj4-%iXq$UHEc}_mz#<L@bAT$C>qywQV z_@#!3^DP8qKtutw-7huO{I{MnZy9<0U$2Y*EF$WDf&R}}c3x{u^kF~!Cg#_&{l!XP zqCVdZ3`|x(`2G*M{{Jh%{}V{?6?Z3HYR|h-hgNHheW{s39jH*>H~3c zZcNA_^i6Q80-qUReEP$kKSKFf*uf;osRA1LaC+G)02+ShUj}!qrjtFl*K1hqU;NxklMZz6>17`2iRsy)d*bk^J(=)UeC z=fA~7{#&8G_r0I1&t7z?wjS=I!oM?_wD3uaPlR@Avk}1jJ{m`6Ei1@T?8iU;CB+GU z-zfkrliu%=w}GC1tMdpL8qNwh+{X`XmDB$w^bfE_**6O)nG@{!Az6SoSu>x_TZ4kW z)mi;obV3FiZH@Q~8-&bp3p}4K&OAge>xJ$e^#(ckEZemucH>>3w4)rTQGx)HdK zr~0p|yYIPQPxdzxY4kLm_dlEO+r|^I_b7@QMX6Ghma3B2)a<0St2L@b?U}?@N~}*& zBW6{rVsC;D)Lyk$jo7h8=HvUr^B>&z>$ z$ENb)x~~0vDxQA9py9pbssaVJmCBuC+0y;on5?Mhg3IM@3$TfT{<0au$?6;%cYG;zD2L~Un_b7P6z!B! zUPko8B3aW(FimE5r8wR{VJcx^kt$n%x?obhZE&B6IC9;$*A?b-`2(4^)UzoK zVDfaz9|{i1BA;q-2quaCF#|_UktcrZ5)7tn(`aTt<9(DndW}P0MT6bx12sCNDLNeB zBpdYJUjg6_TjhuTh)l6Sf`H2i%fk3`VAl)F##J`jV5&t#>|?bz#9N9kG`E7TsDau`(IM!pUsG zo}f8SzwFN`@S(o#>G5I12Y8yWMZ%Bh`>z&xZvBAy4b$;zsjARd>q0yMJCi3Bj5_}o zOGf4s{UhJsJDWQiN5jYrCqEnDh3yxeefN+1X_)h(;H7WYo2%_M8svVTPd0J>SqRJX z1pMZg{RHTdzKb)-oaVLkO?5;robkxuU093~U|-vfq#)0LVkc|U=>vx;ASzcYOROHM8s`x1UX z{aJ2W?M~X7%ZI&<2=i}Z>-m>HxFwG5Dc{p=_V%RNT}AUs+&(z#v=c53vp!CEjX7&b z3+UxtQAwt}|7|;e=WDVx?ETaLkI2+l$#h#fGM0}2YsI!3HPFP72 zm;^s0u`%QunbLqM7n6jnNZ8Z?jzO0s`{Btc6}%3aMQP+2@tiG$h~^&0u{gX6znQ_8 z$s4-Yb2qwd@fE$7MDjFkfRPK?u`Y=5(C;FW%hO##diWP2owW8BXq-4*4Y_}I?2OaE8U3lbighDIlJ4b3jzV{b zYu=kIQXz_q@8m*+#)P=Gi>aF0RP8$!qCF`}r@A$1FW713;B+So$%}rJ$}a?7v#|rhjl{ zqAw}5DR|S-;A=Qj+JqV|82P3ygqDgbS@8Ou^%XOHb$g;u2I6Y|X-Bj8qIkj@8hPDU zOBg5--g|Pg%%Z7(KfI|Hnxi9)ayz!$8_{fVU7~(Na+6a4xig;BWM&>n!cE2_ei`JpmR$Ua4lRY zH)!bZ9JcNq&pP){W*{olC6qBA$Y{&1O6#omuq#iC^~wiy0qJc3=3)1`=TnaR``53^ zBAtQ8LRrND;O9FP(w9+7=OwD{+4I{j=={ke`e5PrSr~^qMhDdqLwg~zmWxKFi#Jjc zrqb_^4E(C#OuR(10Djw_O+C2U$EZBb?qq}evxleunWjwakU#y!A?v(g1zUoMs$Xvp z8;D_S?xh`H8djShoYt)&H`?GOYo#9Yq^1XmXNm`^t?!WYeF+^R?`ZJ$ zTT8-Li!gqfEBDCPmPU|F@N35a*ULEg+XtVt1>^XBky?=Av%6$eeli8W`w7^i`g-E8rMufqAehqW67Pxx^6l79j7tNU7@rbP5q&{-zb%Ivfm6fj z93zLtpLXO;FJM-dsc-2Nr$cCcm7hXpy%?PPa|}$j{l;nM%J~#*P2)N|Xy*rqAB*23 zJl!sE9Lo1v;Ca^}KN6QXS_)Cs>8Yd-sY=HLTy!Lzs zdYs?VF1=IrOkoD@D^CIHy9C|wHw}2cU^?@(Sd<+SL)8>Tf6s09lP@gpYxx1zGu4MGUq=K z?n5^ALLuEk#D3I7W5ziTA=dqE$`0@yA~cH%a_>Tl`We&^}rhy;zNyGIVwHQX}e}^*lnsW0e}L0 z`f?+E$L>kV{mRkH>3gs{ii@}O<_bV}jO0J$kfKCHY5c^u7l2;0$7z8Ttd%iY7ZDdr z4ur37uaADJW%Ga5C3RsP1>Co1Pn<`#{ zAx?9FNT)?8=yTiR3WwI0#Nm04f(p%3{Dl$;goXYd*B)Z<8VVUx2{&xa#VD#O$tIdA zDY|H+vYS3m|86WY@eAd58ZcjzRrpKQH8U^Z)Y!Gz-q88w5&!&`1PV=Ri2UOYm*IkKUH;{5}KX#)<a$DtbgA@`kcQdVvbGncgX%~xbNfVAP2}9VDU!e`tophLM}0Phk|1I9dUjM8)kK>&G-&P- zV;vuz1qZQ8YKPdz(!WhLMAWrTeAZtNm}%@S0tspV(ssD~>Kv4vzj1UnrHU%aP@N~e z-p%WNb$?;jr@%!9D^B>7K=iJLIU^aI*N#Ns>wXCnE}A|~!QJW$#swDeqR)Hv$kW$T zZg0OPI^i$hsVH9#7W&@DeP6grRw@v*$$!zUSP)VWBU=!Y1_N&zwr%qdW87(XJVmFd zeEtR)IO~#3r{LA3-8)Fpi~2*{1@rmCew~4u6t_MM=%6&tFpRmmb$54m_Ec>gXbL0b zvgBI>(*;LwCR1V+NXPs=i|{OV<4;S*H)tD1d-+ z%ZA@=@Iu>J#pD?H^<0kTqfRO}z1A$suGA8UBXg3&XYF{0vuR?77#+y3$AV~0M(EZH z6WWVCCC}N^)o&K(&b=f^yWDo@`q_578}f~&Fmt|BtVuz=`hBhJ>HJ54m8YSAU7fs9 z=tM_qgJ3VYy6pMRu@G<(QcN)NzzkfzKKY|ta6Fhgyou)CAdY>4zS%jEv4cv(8R>3J zHb+ch1sSUPIO0QX#aTy|#UtAUJIrw!{Il-aG%1(vUSR;pMU&eX7h*rq{dNY7(Ug=Z z7BXu<#HuCA{G%Q_{w;si8u>GywIn{ni8p)=`7LQ5n&t)4rh7SWdsc=EJ?q{&Kx-^Z zV+Uq)NN+EsC7Zt;(sTgz`fr0<=%+4ST1s{kZP^oeB&-4w@?xues(9?NWt;gRE=EsB zd_!7Y1*-B3?&EsMb6Y}3-vuh?lf}~cQ^$Et`NL5C(zj{Rs6fcu>^?=!5bRKqux9pl zB|EB=TGs%}Iz0>EKHsK43925~z~*AVtsvodn%?gjQ+2+Z1c2k2g6VFYw#G{Tj4k#E zGchUB{%N{srP^l6ssmQ{SBZ7n5t6l&vlC%I+aVXe-f!u+!ZOkjhF~uQFn!+AVue0` zK+c(6RAdS*87@>!sc##M=Ew`-`b*?7Y8dBE2<6o-5tNku3Xq|686-lxkT# z>iwhXM22EqioL08Pk=40l`GL;YJB;#vpFp1R?l@>S1%5Z^K2VH2LEvG4rBNSR<8gR zx5M@~^1T&-1`S>#HYI@Q6JzkuZ7~|-nK1L^3}VpJ5)VjybGu= z>lhZ6JAKxzU=$jeoPfMQ`DO9qGyqBR5uXMfRyzow5J%}2n0_tW=eG|pyRksUA1W*L`mA1 z(hiMK!x0xXTDF?iKyagYY#olhDa$|H-tUzqbf0Ul;hpG^RxW@4jMEM z`GB1A;j>Xtgog+%Lr2%Hs}oD`d7@A;m8$PRCs}*8DEN#>^yHnKlsBNv2PiZI4Au?KWO`(?4=v^tjv-WqKoLv==zh^*%v;*G4S;^UKe@AEGtw&>>sX1wKM=ViN8BV&w^8 z&m|lJh{35hL%xUg-iBR!h5eBZeeM0X*5FD(tfn|1JOfshgIcnzCc;ckWI=Pd%fu`> zMcHw-sT$RYuuz^kvSh10vs`4ATHJ-|!Efn(hGSVF&&hY4hqD!4^i?g8+ z7s*B9zp1FWbMW0qP1IC+t)crwt-bQU3*PE&Rn1=wu0w=aBjk2aDL`c?AdEBW$0C;h zcxuJIsDMfrMdKw=8E3}Wu;AYi+&zsO`}-qw_LCP$=u(J>+$-K!okcS28+&@75yqZy zGEc{ZLf&eh)Gk{hET@)Zl8fzJ`E~dud|d=;aKu9aatqNfJqc9$%AZC0PqC5VdxU#C z{+alrt7cSN#Ci*)^TYW!dWMlx?5ungZq2zO`}OMybWAm18P1lR1LyZ1a`dI zyB>K@u4rSN*-TGYi&c?=;R=`aDemTKk?|c|!G1lJA^Lm$JHFi?%_ZmNqjXtJys|7{stuzXRy1acC+N_k6QxYTj!rOUT%sdq-@vT>X3Ys}tS z8#s@LvAvi%-!n_OAHK-b7V+pIZGx%Vt^@;sHR}FLAP+d7PEUHm9q(%YZ6)A3qG_;` zq$Uq9iHV{ZPcKFa)xhH8Fr+_OPw?1p-zu_n4(4i@PM&|xhL{qH?u{bK?Cm}27O47^ z?nNgxDRddzF?_-B7lj4RNu&4)58!b5hTs@Yc=HUHTP81^KiwM&td)k$%F^PMT4?z; zMAzG}VNq6X0S{j+CC&&r$fRieoeM3!x3z&8fnHfyi5df&HRiP~`bajv2@@M$`(f68 zi}M>xhx1e)JwwE$jyxTXxp-hYL#9gH2iRiV<#`Q5Le7Et4}LdCoKWyyk+7H9jGff1 z5a+svQYTt=049eLgK(14XITtGxd+XE=mS4{ATRs=s#rLud-7TY@`x@d_qhb#xkL1t zYD!4@)pw6E7NvbGpwtGEkES4A*4Zz5Vse!r{l72_)>8<-{+7 zDjJLz5(F-FeQ|_O(!Nb7hELZD_kdz0fl!Ga2Gxspz}dEr^_7Fc8Olr2_ZlvLr2FYU z0oH)u6uoky*lW%oO0X3P+`kpJkLygN<2muXWfq)}A?TDF)c@Pw%rtDhxWRj3_fz7K ze}6(;W5C4I8lyKs1^0H@pidb@wI2j55# z&OP?(g|%0n0h>mDQRrJ>l%|(5Ens~oO;U<^TB?(P5!7Y--%w%)Kf3YRx9%G@BsO+_pGxDa5miB_Q_sIG=SvSXp~v%Wv|^zLp%6nAKJ5H6 zmY=*(8Q}>Hy{sGV&6V4QaB7hVWs$Y>UUoguH_!B6HsCPB6W&OJ(03T)d-z4wDz=Ty zTI@{*=FF=;O_dIww!~+Zu+(1w{gE6^sNto9Q|-hzzpp@YtnYGS}s`dYG0=z7BPy_eqbt+9-+s$3GNHa`F9|40zm_*G0s^r3;9KJ1Rft51c! zW2CzhokFuNMnTS&(6OVpp!}<$vYpD&{oFhKjw2EcEe&b+xc4i$IKd1%q}rQ;OzPkH zf6{uyJI#o0u97Qk`QXq>iG-Vc^W2qN-?4BelW-CY!&KctF{E?gWzmZ}ym=WHKCEbX znSj7xP6+%IN{Pw6X@)Y`icdTyfW6;}I)`sNFm*}ka^f|m)Rgh{PoU1*d^)rYHv4Hj zcsbHE`Ge&NpJ*A!z^Y95#dd0-^kguV2ckxoy z!Dqi+4X0=F;?{EF9CwP$pZ8^GplTVsiS1aEy{m+m(gri<^qfSP`uUG>yJ}gww~Dt{7nuapD{hmjHX@!IPx(dLzWX39J`>OD zew${$;S$YpN2I-SGI)nt)mRh@gVfK`eun69wywbW>hRXZU}}tkxVlXNQS|Twd>fzf z{aSV55ZnL8-4^rju=>K0t?~VSALqSTO!wRwzhWN7%2kaFcK!N1m6os|_;OFdzW1uJ zv5es+v)}17wroWv*6~c^wCKz(`~Ky4eJI$DpO_n@J?US`#rKs-W@zY^#$)O0Ee4VCD`^2sVV*o-y0>&}|;NG0t>WL9ta|eZsS! zG;%M!m7f$!#)B`}gG$c?nrv>d3BFI3{E~~m-_oAE@>6;eKu+aJJ_gxqby4@Qas>(= z!3}g58gz3Bm5D#hsN}vw2gW-R@gK&nfLaAOEKnK-RvUU*7(@M19Z?2?9cp3 z%mhKT<|lZYw&#Z=hVLSBy?&S)H;;p#GH}WHFHrhxtnnO_UUS>x--*!7DF;#J@x^)G z9z@F<@BF8^_A5akLh_V)9d=(uj9)AIVTiAt)eVTJ(S+aXYFS$(ctQAbY)iL_S zkeh5$fF=-iTNcK5`g8vQqu1XPXd3(bBcybGBRkx)rfZLRzkHWexcmuckVqoZYUF_?3Zp? zo0A}_fNa|X_>*E_p((n}bL{g}G;~D@SArZsIITh1<@{BDj!}c-NE4Ef8cf5XdfpDM z)t$%m^lU4Pr1WW~Y0CXvcz2IKWe+xeSU2O_ZFVYaxlr(YCc{=G_Zghu;>n@R8&nv3 zK97%OqJZTesSwTDG&?=qhH~#~KHM9Db4IMoYU>UB#t+}2_dzpF6*BQYTr7Uf#1K%? zc7%P3v-&l~#a=O3(P^a|ObVubl+5S}QD^Mwjc>2nq{Fu<{HND8)V%!uR!!SCrRo+nuH={cUeZw_=HYn)oA#=y%A~$*NC$2OUdXx|aX}QrO z@(rD?;c^q^-h~v8g-Iyi+o@r(0n(G3(@%UykbR|K$5Q^86E1gwuv5 z*f{78JprD1o9c8iT)Kc|U{N73r4$3Y3*WlR=YR{LXIx7~r)lR0JJJBpkjMR2EP0F*<#S2E~pVO&lXB90$E>vD& zr&3#gF~b}lrKYL7g8_orbjj43{By7->}h9sH!hlh0n7@1or``6?8 z+`2z6<3d?f!{$cs{VJ8VePzn6h~icV6e_*1nR)6|X7hOJL>3{iUtuAhDzwg1V3Jy8ctl6R%ng-OfGpt zI98jv`f&hW(d_(DP*UbD%5we~`mE-TBB^(9Ig3p(gnZ<5CFodo{PB~>xC1J@r`B`S z@HGW4BoxXQV7G{$#Tn2zS27t_k3>~>2*kF|96SMUy)Pv=)J|y zN6BqQ41e|?!uz7=my7KhKJJbg#~tCtCj#F(V8zh>a4V@2-c>?h2{TUFBlDBy_Sd z`{)33Cd+Y7dq3i^X-VOE^E2#>eE?bM@TvW07HVkxLp%L&wbn@&S_iJH&@sjcGQJyJ zdRDEVv9ne?nq4S-N!SP(@owt7N6yvF>6a+p(CY-i+L14&W^6J0C2E|`sNyo;QHQXC zJN-TFLQB@qzS&RhAFPF`eS}}1cfw<6<^b*RxU~~D#9O5tNktC^x_X(q)3Wh4r#K`g z$LMl`L#D(W=|Q0F<6@d%1q2_2n)=KBQVYq7cPW6{zIQUX=ez8~Yay$i^T7_2AZ-NPIw}=9j2v zn8a3Rh(5XS--QF@ee@T;VZFu_m)ey!$~WK*i-DutkZXw7cm`eYI60y6%5i^eE}U7f zWykkW*k$iA`zVn%-ojuLW&bd+D@SJOKTk!OkP!Q~a*|FM9!{nfplHX=Qjv^p*E!QA zFZ1K=;4IYCio}EA|6+7_1)ikSl7Q`~H$|mFh$HYm*8@qC#4iS_Ral}nLaCm#(hT$x zq$b`yfc_T`Kpe)#!~~4>&C$M3H2)L9`Z`c9^3V-BRNurp;!{1({i6pP0nRu*H5s)w zVFMTlqiyQULe^^T>)lmn1(|W<-=NNYJel zWlRqLtDmJ3V<0L#dajbpDuNBHGGS_ap@qXU@A>C&@!{ix(I*ZFWc1m{RJSSR`-NvV zl=j6)SbV-%wkW67vHl<6_VGo@ADTXVJ|w}m!gZ)1AcFAlAKsfZAM3^aP?5q_m%EEg$*$m_Hl z*;^C_(J@&-J^9yN2T77+r|3*vQkbR;wB{kleTS3N>>4I&o`OqoNRphL_J`1$n4UXSK>+z**0B&)R55jKoKeBLL zco6Yj)SfT7Jq;K18p35G%G2V1-O0gxOr2aZI)-t53Rt`AXRdhe$ZSxXpZU`)>{r~S z%aup-xTOM8>eO;yIUwqkC0wJD!g(2fQ)gT-<+yQHzVzPUKh+G(zOE0Kug=1F7Hln`n0XF*QH!?a}Vk)_Hl&g3}oV zEP6noqB&UaeJbW3qAnxh#jD?o%J3#)ac~a%{SZE`%&ke9XLlU5Bd4r3>d+c#wLcoR zq#O>!{n@R}bc@vubx1N{5$_Jc?#G~YwFyO9L;Sh*R6AA)Q0d@Qj<)7<@IqP%JCLBs z2ArAuR%60Qv174J18({@!F+z+g}a#y{XxUv{ClBq@)75^Jf})dI-B#E7pQg10jJ#S zI6Ar+z9&PUhoX;U?YNSkNR~q{Bv#Rk49s^ZPZuv5&3|bXbeZ$BAX$ZC=kJ|6jelg4 zETx>pa`g>=9h|D~Jq1%@r6*Kn=#opl+s0h*REkHEud1Z~*{sbl$LEFD-+;y<{l4cp zi0<;xg}xeoP5uDqRi7%C%P0k(x!nhE=%*ZF1PE^uWX`6Y3LA7IS}h$4?W$M9+k;U^O}qs zGXEC92m9ce1HSMk(F*oZV0ZVae@8UnVFywEvyviX+BtKHelcKw7+$&3&?M+Ts&M`z zoAyNAtb0%r`*ySz_AA5rQ7bNVKKbz0O0(XUGOEQU?f3KF-P=wd@pnFqp)x3@$H{OB zAEMLkSBn{~Pso%rxWy3oY>qvGf)pt0fU|u=><-y<3Qy~Bc#L?n#o*OJE$)&XsnpvX z`A&=x4!r%NS}}-BL;S*7*b{y-U9Iek>Q}s6Aj@PhK6mR_`J${ZX3IU2V`;m22y4z@ zglM{PK0g=H_@bitu#z7~5CV1RT>h{pD|g)Z_2P9TImG1ts>z2I7+DDCQ-d3&dQ=xv zQrh#CAiepnn)HA}+a(b2iD@sU;}ns{qJ0jz2*GJ$VErPlWJV?6O0=LGk~AWRF+LGlxYwlitbKNVb~wBF_H@;)#&!P+M3#&Omq9&v z5?=ZhHFcBbG3)6D;|RYgPx8`a<6^+CRuH$wEMsi}muu6>2i5&sJTQl&1ny?;d|;er zejD1IX7ppi>diRqxp=XFf`OrM0*eFNhmiBs=+m^ROFcnA-5$i*DMvO^d*hB6V{E(Q zhOuLGvPx8;*sJsHzF*C{G~E4+iUS3we)(rF!VTf5Rx6$sv~wGHmr|Otj#4n6UV(VH z4cguE{}&(y4qrx|P+WY(U~PNwrK=z^IAqgsjqLu8DRenHmnmubw?e;P&HAWNNm*9z z9AhngbuWhLs+KA7&KJ7NHxIdn1~{eviH+V~ZZ z08ls*%5$c;@{l8u7G0M5*7l6VcWLUngc!lF+vLuC5$L7cOA=w<)RvXQOVH}cBuj%_ zSo<=P+5}nPU**hqGyn9svM+f(Ru-X~d182!<28iT)zTHTSu*!LID72qd_MdBFP)Lv z{;>i6t__!%l0~8=8|6UxQuw0KeJz|hWz9U4*!^a<2#yvJ1BrcHe$Elbwg^9YcQO$x zA|23y9>JnF^&tieu<7mHX$H8=_U`+vA@<-S{_?*sLd;rM$|%mMo8Jp&4<^Sda!cjK zCTkzt2YkMF;W3KSQ8qH)EBbw@CIX|ZeE8TgIn8ZvTYOSebOx>N(XLq~G#~KHN}0Cw zl~ia;i}@d`d~1=thJAJJGjSIUdmH1m&(~+-I2ljDBI~z|_XRUdY6=ueUGYx4A=d2T z3WAmx?JZDPjvjRXir(eq>r(|$6>4vj*}dtCjUC1enXNm`zz7nk5alC6Ta8LSiT)bN ze@ig_GL^5(V3EolL%sd;jHrDZm;Z3;8~*TFrdc@sQjXJEK}Du?4)0A1G~A4#04%#S4sSLhECv6e0>qwK+3Yu4`|;8 z9XoGqNaeeEL=-Hw{P<4m$_p8w$!0LkYsNHF2)}OcmX}C5y!Ib9OUn)qW26X}Z`zaA zAuLNbk|{sa75w+_MxPy8(kE&OsM)YI(z1PEjL{eC-YMmNu(5S@1S-Z1_>+6AbxAqJ_;f#w57I`{6 zcK7zBtPk#uxorllIg*@);WAS+)^?DxQ6{-~jjEJIAtzUAkI8wr>mdfJft>>D&Jbcx zbcZZp`t+esN{_?N7l^&sf3g<`JtbwSs)}6)FW2j|kyqr|G<9)<@D*^GRd|9bb{%hM zJ+bk__a#ZBE_nK;qQ`pC3bGf~=ZcZ7`p|2hoXFk8kUZ30O&Kh7M2C=7W;UNSFh%1s zkWy_?%x7{`-N`-htfRrp72)+SQd^XEuW|d)@LcI=H2MiUhs>lgYZdy*d#E)nG7Q^b z@5z*gdc=%;KM)^B^kCCjU>Q68q(-N1|1o2pUy5M#9dmQz8kZPtwZ=*ar>bDpn!+BQ z#j%sUBqDzW2YQ5x!`CgESGo1HqXnqyyuQCd>MdqPWvJe{$+zx0|` zc#ngIs`h#=2N3`|se5?oY+V)QaYmt%1#+4@nKGv#ng% z=7_^DEib_&2lOPhxUS0Sgcp|CmUGbOnhZ;@FrG*HK`&1|PDt=?ZQ+OuA@+i89HCc)S=H?HS|lUmHHe5e zCpS>mmFbE!Fy^ciZZBV*CWR{qzz58e0l(%vp9Pf9>aO3*E}KG6o}cD;HoOwhInRW` zYn)dv7CLyqHn?Gka*o2R-Z)9}Stz}*!%e)IoIfq;b|p4ir!FUczI|VQiSIYgxb3qb z3;C{4)Z;Joj(3&DG;fWn0#^)p)E}(MZHt`YKHhdq39omjt;T$V(jH2YUM79SWIk|R z5Fi4^7O%4W=P+-PvK`?%greUJdu_P+qfV>o=FN~krMyYgg*69D+^28^J|mJw*9h^s zXZ9!N(R04gpxK-l8Y4Ite@8ZVbc{}z+2N!DnZT1S9&9A`2f$Krr}7!Y@is=Io7le7 zV>wK?;X}waN~0cc9@P%@1>vZ7gOXS?bE&#Kt2GHu_!j;A#VFEnr>v6--7F3vF^+=~ zLw?n*O}n5%x(!Rn-#*f7emfDcClXZs-eEP6A8EvQaKi{00p_K2n#Z-@#M}u;jlEYd z?<`i#_g)5CL1bR;@OQ}47&DeT@>m!cbc6%eMLECG7y;6wIzroEjKmP%t0sp$X<;{| z4*xs4O!a&r%1I2Y?K|0TTimtu9;b0L-6KSoqopJ~g?H@J9%1foNZZiS`eXiMpTiu} zRPw;pn3=ck9inctZt2T}mvp3B8g{Wx4}WE&4Vmlg<frXpIv4(fpFb;pn)yI`r zl<>@gIACce{(ryut(JEmG_<0z+C!*}?U!)m!Y(@B>x%p-|KlofwqdYz>*1X@Lv6iA zXf=rJ_i@ETmS8A&aCi6S+Z?Ua71M$1)~~h@_$Auad}1-U@~;QuHH6Ql+Ak4p@P~No17CT$U@a{2u)S6wlVQz-@Hu5e z*65|fEhY)L)ux@sG{j(T|{%rzbLT&?`v}&V^-q@KcZKb&3rcT4Jn%DZE>EjV|ew~oFgK4S*P9LCND7TxK z4y-ADjd2|rK=p**&0Ubq z7;yZs?WPuAfA*9PPky1q*a=IS?JdM?QCoXU1^AG+!?`FqiWK2&!fZnF7)kFj-FJNH zAn3v+){5prT*{{DrR#VKG!{1)dsA+TJ(5M|lq68=Zl9Q!{u`pmPnw~OH~vxx_Xn_{ z@)S1VEs4aioLSNN3NwaDde3;5q36lFs}XkQ394gt(8tRcYv4`{=Kdt8|~EO zW`F87G$a;cOkevT{28>mk7X$`G7>LJY{QYS(>)6h0?yp6iqC;Ap~OJ(vF0hHS|HfXt$oTD_eKU0i((T@*!8&(yG3zxNT z*Mlzjt>rheVDA&}$K31}b_H|@fxELf+K6L4bQTNdpWIpIdbPNlx%!c2p*^b&JE0Hz z6KO|OnD$_QcIPG_u7t=#Ex8wNda0lwY5P(hKGiRx)bU?iKZ+aR5;9NOHCM0ytM*b+ zGe31wW?C538|V>5|9Ja>M@YHkR*j~)4|*D(r3$kLtYy$#)~M2({Ik4+lP6vBmU`Vl zvvHYmUI+iHgKG#kH@kP*sO>etH|Ns&0}5iQ+s-BeM%MZkWOLN z9Y}+q)3oA&q%DgwfP5{04gxs7GB-v$$gT9fwc9`e{sN|g`wP+?(ue1p?Ux(b@2M3m zaUJ*Fj}XgMtQTNUdW3P6V==PrqFytny#oCUoSRaDt`;W97X>a}`?NMR(r!Y^HnkV^ z+J6`fS@sFJesWZgalq@u%2U)!CEYcK?vcqK?>a|H{C9%+58UC?J;q|-J%AnWMQh~7 z`#n|#dLH7QZ!BI11TUyG?gj$W$~w&QhYO621iVVwSzi=qBY5AOY|Yk%Z4R!u3z~T| zLcTLNYhJyv|MCiu6))hm08Dws=>(U+i!PDg*c59NRPgT})5*wx`WNz0*XzZ}AE2br zXRboAv7Oc9#Y7VQ^-nJN|JMSLUWqiH(k0SFZLD$uuI(TyZIgr&GI_eUuO zPS5ehhu&jl=DY)Vij<6s2J13#Pbr7M5fu`BiPu0^>U-zio?MMQFmU0%&)y?oU)~tu zkq|vOJsG_&A|3jz8<`Ns!VIcn*p*;x8+|`{ea#HQ%=(-t+S;@hygs zvr488CZSZqTc^jIak{Rwk|m*|!Hvt>k&>P06l45RZ|I>)O6{;WsEZeJLxbw^xYLlg zTearFYx&%4bh~rXCzaeB3L|hyYxnXW-{ncvynWr6eaItEkV667lZIg3^2T!d2lBoh zd2Q68{3hnLT}SkIQ<*f1#Jl+)M`;=s@sa+;BL`>RoI5mA0IO06 zr5`G|AY|)*?i)dcP8xQIi_lVG$|KOP?}m%Y$pj*+9@g6Lv{bU^~#0t+t+8gUh=B)+=c4n6q7lggU@& zfcRJbk#IXt_6rILpz7`ZFCl=WaLheQ?I;r?qGKOj?{js_@mkDlNrsrpI~$7wK2_>- ztigLjHZzY{ivHH2+A|g=Y^p@H%a@uMN}@b zrmx+K-=o^W$c$*!8fFKOWQ-4k`-2~HT8w&av$rdFB8Gyn#1qKxs`fD#x#1UZDG!wQ zjP=IZ&PJX5?AhUnVzuTX?W<@E`vU+4lc)HO<76He3LYau;p;^b{~co#L> z6jtlIMJ6=?CiEkwh<7x#&w&FK9BkFU773v79?poHFo~J`g7xHH@&HAesHja>p6;5+v#Br=1`0O0Gtyy`h7bVvDxigsl zGDz&|0caP-lpmNwU@1c_cyOKQYj|+cuksLIguB}rZ%}K*S(Q4gk7qJk5g)ThZHpBQ zsqtLni|HArK%6mwP7v`XQd^Qq_gKu3*~Cf7&Mw&gYFQuJ*yKjP-~|%IQS5izDu>{VdLn?2MiM75h`>q-8Wls zl=u-MIE=`$75(skYj?n4q?+n-5s5TQidYDq%_Srjutk_$oZJ>o(R>W(0dS1fD~^+Y zPvcC}ss$u(-L8)r;S;RMFS#V*%UJT@mhaC2?WToD!=-RJ?#5eU{qW=sX`1M$a}Ij; z0-Ys@*Q-QG&L{pq_?OzE{Gv7-x)~rOyi94M>7`u-yWxtkW7ceM%CS+ko!SL-&-)cD z#a00id~)ye!;MBF+ggy@CE+=yzf8Bhlk-ay?vkDt3VmW&{`13|cJvAy9@3UX`fgZ= z5*wf^5A4KX>Km$Kl?AzqSj%HxAK6tr4`S!f4t)x zMHt3?qs_rDdtcqhD>R~j0h&FG*P})|B&DMj+~~bKVs5W;eFm#ZVjSAU8iAK*vkRdZ z6#n54`~KOJSa%R+rgBHPwYcNS_2w1PR)K~t^n^phR zX`=b)PmI1#p#<<=k!U2n;zR#SGE5b%-py?1=kjdHYbneGFp%lOO`)+tN9Ms)j=2mL z$fL{XdnB(*ki&H=RiAI`))^pfaa7v?r0Bd4R+jSDROiZhK+lCcpk2{1R~m<4u@8BM z(Tl#R4&rO@w+Gw4YyEt~%hGd73P)bvL?6ABKQ(T?88zyn@Fc(CJ%QoqS0?BpRzq+=WrIKYgy;7AjL^;=ataJ-x zNk_Rp1?TflWx2Kw4U2@UPznJw&x@N-Z&WP>1^#TtM=UE?icXOjrybN{Ev!BcnR4<* z1CznDM6ieY%bfJXbNWs`^PE8s5W-p8bJY9=g@Rx}$iu5%agHGP@|kWy6`tmj|knPbK@Qzz?QpXO5xIS5Y{kynld~8k(Z_mg8vJg%FUjp*R*A>Qh zxyo>XT>{qi$pr!9XoEy_6Q}v>_RpmWrHO2i0v;&HUVQt3JDngAS*Ag?vo?WET$T%! zL`o74NqjO$HTr|V`sX}Q*kK{jV5mBxV}B3}@*%<3AR9avmlFwI-_7tRPp0p_=A8x| zF(X*O)+UD!5O=;j_6H*tI!xK5pGLYa8S;wtghf_GD>m;>_$&p^GLAFkTX4|I!>?Xd z>@GqT5C!+hZ7;>_Pq8e1YGPMDert1Vq!)I)$kxAdl_6i^GIfyivs`ZwOps%o#wz{W zfgQLEz-_?(v#*@CU)U*A_q&1=S3`CR1(zO})UQ_(hv+rR_53mqz{&K)em$r`|4%klcf#6}* zMpl4zy6ktv)YPGF>jUeZMdJw4Wcmw&uG#rDs|&SCu(1?jx)+w&CCYbR=mUIfL}TaL zu-ynWO}RLgAbKz|POFK@hQWR~!3fT|KPHO4(ov@%OQ4<4e)>eToL;xUKp2y7cxMpC zxxW$wVk0lO_dgRA+t>KjZtT;SAE_B|urfl&JfNx8c zLaDf#X^1^8o0&L_vdk&ZBTSaY9iLqpNEpI67QdtnJ)yVJH#1|!aR%XTN0?Y&s;HLU zp-Q`iYuUb~`RuxfSff?sN0>WH=lKc>(9M4q0%%(K{MiQl%{80y6D2TgZm3%6-KArv zeF3YS_RNXvly0r$7L4z6Vpm=g>pvpE(jdg`Xq+l=f2tm z68Q@S6(Uk~@US~i=xTp?3QKQQPHnZRSNtDM=lxIh|G)9GjAQSa9Gj4t%{lfSQIvHg zNg70D#5u=aWkd)$L`Ld^^Y(f^uE%x1F52H- zw;(wEUDY2a$es_m1sZLQLAP_PvYgjFHleD~wXFX)eX} z^~9A+DS;S(11?@RqjH(?`_!lMPTy^lK~^GrmhYI9a;}3+2qTT^1@mYgAUMq!6_%VHxsEUlO*7%j7eA2pvhd0@pJB(NM#TNl zG`{nf9%Ez-ILYSwL%Cr)a?f3lLw|usq!k%^2iH&mF`E_xW__)*jZcJJQE=!~3!wlI zqGP=p@|_Y+`UOcOni}puKbrd9=b~jRMJ_G_y=szQ|L_p&qT48N)JP!b&Jr^=IPlxE z+t>N={M1|Pw?OU!dDEbPQHg(}&2vu|t6ya^eVl!>HvT<>g{>lMCi(k|`>o_W4g0EX zZQ@s!9nBndo6or`m2j5v9mh_@Bqa9EET1!tD{y3vc}Iz)>K+mmf!uw$yyoM;J&kt~ z^gwqRV_rr!<*d+S{K|5Ew!m6c$*ADAVAayDi(hJP7gth$%j;0Re>3M}8XHv8UO5BL z?xNH+iPSONvq`q*deSKswyj~nG9mmO;mPsBAIoj^*Hrmqtsls5;$4T}oelBCaqNFG z1J}Iwmkzk40m{^VNh=Ay#1O}sd#rV!j$3UcE?KG1KwUX^qAD1BnO>*;?(FA^#Eal8k57H`bxmj#_vwgKbI#o8}l@4Tj^YL*p$3x9plH)x4J`p(TxCvUxF zNR@MP>G&G68XskyqX?B zWsm-{K#&xX)i8;2B##3hU|hg;Ihb4r+OfU~$ylo|4|C3}e!Lq2`b)8gZa4A2ATAC( zP%V|a>F~RzjcD!N9wFaT%y;Kfct~!+@9)r?dpf}lJ8gWhvGGxGcpj-?;_vg#gQ#&Q z@^PdvLK6HXS9FyQ-TSnOmE|2tVkT_pVOlAZ zxwU<=MFY*&-_(Cw3PEKG^ZU^HW9&{|IA;Dg6qaPKnN>)BE5=4J3F4K2G&`R3FOL{{ z!%H8B>nKI}mjW{jJXtr$!H)IY=>D4%|Zkiy;3#7U$aClMnxh8I$-N%mv-;(1;{DVQ8yKuDF4C7J3G7yd}n_lW=Uv3<{)MnmZ)^ z#aothnjc5zTRTl|ItVSrm4(o2V z!XcG!m5x)%*V#KU?17fsBOP>r;^!|IUa+-Q)K{Fm9c4CXy!4Vy9;J2L>m%K*IOfz< zyL<0Q8k3mzWyxaroh~8q4)H@_IXjGw&2-n##D+R{4Q|Ey2K&cXlUy^_T8ytwI;9S~ z23rl^sPFhkouteol_2E#|hq?&(>?Z6A;& zLRb)qI4`yS`@ME2LO#!ZAngp$=BJuG5#}*erXs&sOxxELC)0xkbDn~h#l9n^3u;v34LERlVgFs*b5gX8Fq+UrSl%aEa_ zYk+LPJ>gpiD(xkE^}3cKz=cTOA)}+T@RMNG?NOh^kj*)f#yL9kQl~HJ$&H%jLk<^cc;tdqu=J3Lsb^ZZ`U7WnOqujPqZv))$mkj zcZZu%uhV)rQ_!YzRTp&?A(i#(z7dM`aHj!4DbtM$ws+}IV+!v#NrPkyhJqXwyRFAa zDp3i7)Q3Mu{|K+=y_nk^ec?*Fr-WgQ@&U}Q^hG?f*6S#%$xMm7Y1^EKdEJJJfY^(-CNfr9trUbje62FL1ULu-<}z3Oi!1OaWM9!u*!d27hsX(96mbK|+6ru;!l zO?%0eik=XKe24qRc$V#?ybJu%c1Ix4mRasLwc`6S*{1XMX_W1r=Btn%-{01H!+7~8 zBzfm9iKFg2&ivyEmS+EgL@*rXIi*ZR5ao2^!odeL4@3oHQh+&`vE;B*~NFsvtu z8bPJ|1^h^e<=mYa^&!gv@DwGKypm6kjh?mRrFXVCcpKQ%QwFVPCAr@SOGOJ(L&Urw zX@$7iD0wPv5}K|FY>x2XJyiFsk!FGnW4NJcUkezx7!dHg5{R2zA*_p??Iw z)%Pa6uAezDd$vXl)F7%WCpbhXh{hyPs*`r1W-QLdgbMoZkKWrM^N^{EXj+-?!;z-1 zA6NpK)n8U73ZtH{3w9ZUXEij(3sf-+)U-)qx;9jY7 zsFbM)e#%fkabcy4^iX{xVzpo7>|hW&ksHwYxPGWv)FD8Q6X@4TU`u+)82?iT_pV^0 zJa6;5a^oW-gL+9m2a6rQAG1X}X8coUj4!JFnI($EU0|3Xr0Sq=sB%*Q$S2nYZcM_ouM3Gq%zY%_se;9Q7zF+-F;-26hM;D zdTG=yCmZ%zIFGpwR43kV3pXp9dU`@qo`NTE+}_-RA2dW|?e#q#cK8^Wb(yVT>ckx? zeZ*dd@~y5KL+Zel8XCz!;}RtN?6IM+{P8Jy*JO$R3@<(#{0wwQIFPFYk+}A_M0XMb zfL1)BL#1C^g+~0>>yaVcwW}in{OZ%-qHj;nHH9dAZz16O_s93)GP{#OavkwkMU3Dds3tDxp7b{gw87NgiA`Jk@=xEjJkEg}|+xaBHpQZn#9E0mtpDC}h#G#xvh z^f=x?S>fsU&nA;-%x;a~v)|<=2Ulna8BeqBwRT!T%rF-mWG%sEkz09Y}gIobT1#lQ0i{HI>!qPC+H)(+D z;*exG0v6=C({->jM*U`^@}=a$)Sx*EHk!|)oCJR^(xt(?boxc|n6K8#?Jl|I$vsm_wOy30nVsHfn+=K$h_TPgnginzJvyrQO=~ zb?6NIuRKZ=aevR;5AND*H4}>d?_BXJ`M~(vOqcN(;7$I=evR{ z5Q?x8z&g+1WWSL7&h!22lcz|FjrNOux-i6u!&>;&=G2`N!OaPsG<_n|%&kCIf!02I@yQwa~KIEc1yd3ZDnD#&9!|1%(WK2NiE!1iOp4@t3>xMKn zBOOog*eIUdE%`wj{PC|(@5bX1y{($_EW5s;9VKk9hSa;WxZ9Y)bL)W>xvK+L(ZuYU zQ$mre4F!&&dSo= zDs*(4H>L$eJMm60_8vz4Z@yY1V(uC|8xJ723z_jagC4)3&U6X>v10iu=NMkI#MyY_ zDCb3&Ou|0gWH09)fNe@tAHC;lY0mLzy*D2gBeoQ#Bj|kMy7^Fn(I$CQxb7-T%$!~v zCT-OQljUP%pj9E2Rx0~@xJGhTY?3xjOeQxk&Dv{!Ndp$DzyT<}^p^1Bk-1$c>!bGJ z0xwWO{x9#n0yWD^zYix+Ht&3j&hsfo1ok%D!-|MsTh%069|?xHN$Vv)tE`2#kjLN|2l zpIsffep5wgbB04Qy7B2m&%dLHqr;lskmY4Ki`|z_grMG&I<4upUX**_A0F}#FiiM|9PrtAK7tM=3^m=Kr zO7bKjb_Bh+(2)kzj5lBsvWpd1Pa+>$Ype%^4CISrWFv~HqY<9_JC?_HK}^t0?;#*~ z{Tsz6lU`mIb?lfheB__bQvD;=9{lHr556@`SgoeKY)SeVr&|>`XR3VUZ9IyO;Y>2G zc!pgXGqip5HUB0{@~pSl9=|GWIBhId%SgA>{>OWV#Goga*MH7s9qEcaVd#%yZYEn| z-cEi9@YAMf;el`~F^1|3y_Ej9OF?cc8}vckuTD_jNpIgUG`TVnkNumN^)j_Hp4Cl zgX-SnRX6ycZb^U{e+9Ukg0AzF#~URR#4^VidlTkP(vCLCZt7{obL;vE`gfP#2pH9k zly7LPP_iSBU>xS3V;+6OWp3K$u!cfu`2;kRq>cN^J#!Ic+ngAv1>brtfDV^J>$V;z zhPW(Pjm6bbjj?p?1VDu3!gqu&Riy&NWROR%8Xvjc&2d>_rFpYzxu1ox3C~EKT5-Fq zN&^Pl!|UkNA8`1BEYk=V3oD63D=I*~cGvz6{hiA)scR2sE{z39 zW_D6P#DUkNy@=x!ozGNrMpDgk)8Rgbv#Fp|`5)zMjoTfJ zh7uXG8Dk-l-g58Q#CaN}UmU>}B6f^DH37}^1y>&rYrgsWyhK>?w#T|_u0aKa>MB3* zb@MIO7gwPDU%48>Dy=2%P*wZy10CJY2NNxWuSo`{*fspW7hvsign-X6&-jwKGa&_n zs}}IuoDAnE`9QXsps_;fB@oh6aCImXrvWOt%&hyIfYW^uLD*XdKng!HYpcl+Z{_~+ zBoAl`+D0VPu6hJMS<5y#0-$g#tCtCnc!aL_Uj1~4n{D}ar9bkJ!qTr32cs9nO)e3A2zxOMxPYipVI)A5P0 zXs!oen2aVS;ioC&u`IIE{_n$ryt7TdI*U%|!Al>FgDWKKc8_L0%9>!q$|jYpfQ%ijci-AC>r#Pj71A$b#je^kGh3To|bac9Id-H=z& zG|lXxF|6h20&F?9-hkT2uUbSC7t+5f)CzfQ-5<1NlLlU$GQLB!+fzdKu?yi}_!KKfPj`1X}z+<^VeVVTQVrw*P% zZnk_lCI|tz&Y9m`#tX-fX$9Pu(+XZOXx@A4GwR_rbA&WDfa?VExPyOW2pnD0_S#eD zJRy;+=2t9H-d2|n6Ud@BJfB4I#np2ilYx8n8Y1tXEH03L$@(TI1O5{{TeC)&x#~F% zfVyu6HzL?6OZ2k4>~752@?Jy09O15xCC+k<+nSK;u2p8ZwQ)JvcP3Z;!dB+hR4(pt zN3vUDrIp<1-(Ksl+N?X-m=}G*5U=^vb!A-c5Shk6zgnnlNWT*vlpUWy>`2Z>u*Ix} zo{n2CxX25H(@r+^3)q#I-%fpwOnrU1yW;MOF!lv&h5ze~V`h3shCP*wYl4bSB{7A# z^8RD;cpG#{?cxQSXp2`16F10W6Y}m)FVWxD!Lb`K;OcM);jXCvBxCZtmZE}mwH~?k zr=}jq*fpLyKZCX)y4vL4`&+yoXj+}Fky;!BK-Mt^Wz=-K>^As$;FL75g*pZi`kIzG%Dfso030r!y{O3o#vEcOwY z^+~dM*y!$iCy#|gYFZVdN^1V7*AIc_L>?fj< zS35N?8|Y6y6#g4kSnz&<^WRz|D@~kPUnzAH5!576`GJdA6n0G~Ay)!-Z#t&_wnU(5 zSTI&bwz{|QMRbn60AuEZ7E)}1tEsrI^e|gIb8jHf{DnkQq{afh&i*3-$5wLJ?cjok zEdU#v$6-uJ?v|?@+FyP8;!Qd%Pju%fnQQxW5Mq*9c-buA7C^|Pl^^D98x1|E9IW^4 z|7s?I^Q&mLj`=q%G0dHDA`~Y7S8C)L4RbB;h&jBD85fV~4BybY%NqacMp6g!z_0i> zdREeVj(GkHd^OWxz`Ko>(RW`W6(@*HJ4(q>+dMxJV<$C{0u@O9Y6?H(-bqN*3!Sop zS3r#VD6NR$cezj1W@^UPxY?k?+~8$Y>pQ4GMxNLZbYv2_4>%W@bX7i1>0l28m35tH z9$Zx&Hy+fO0Mf{WA|S+gz|)XsJM6(q=d##ZpAds%y{kxPyxI%FCkTd$A(F8vTFmE~ z#{(24UU}uDjYj7b`ZT521OE41;urjNp>N?1Dza=pD5WnI6`Mty-g5Wf!~588rw_3= zG!V4{mUB8vo;}^}p=-{TR_IjYYolGA$ag$-oyW!4kvUx)aVd2@SwE>e8Q@5xtl0vr z$OUi-AVh?+Rk{>f8T^&9@J8GTrO?@Vw04Xu3#(^q+wJuOZgiYI}6XqFGjNjm!?8(R#Bd(2{B1euclf zfH^E0l6yJ)Y?I**R5q%SPutWl*I1^Wo(~)VAG`D7b0Ut9<8hei3tg)IzhRe}Y-QXt z{G^yA!`$2ms${IX5 zBf|NZ^6$x~*c@LPOujfdqK?tn1e9M@_ZevQVI9DF z<|+C&EK@g{TXjA2&xYsqfhTv;p|1L8z}Gt$w!bAx)>NC8K|@+OSnBHmP{ax=Dqg!W8Yzy4ea=n(-Y{ukh?jlcxU6ty*$PIiDRpv@Z?cen_#IxnIY2aC5*9* z*?WYU*ZAe5E8iar(3;=lLew+zkr9k;b3Xq>dA{dB+Waw#4l)Hkh{5mVkJLD^zX883 z=NN53w}9-Bo3D6&Q`4u==MAl8_?7ubjjkUJRtQyeaM?3?PIxxs^f}F3ANuf~C>qtm zy*2a}1WQ+ZmN}kvFK0Y&;d<`{5z-kj#_MF*ikTlyKJ6n(vA!&t*+c4gpf$QlWw|O2 zSc1;WxzFcQ-9?XP7CZAwqK~jQAiM{-5g$I_9jZ519rB~y6RnS5Uq+FPuMKx~qQ%)N zBy=rsi>gyTrow@n&~ou(RD>-=2vMR*6_4f;AKJ8J%>n9YcyRsHl(B*lhoX=$(OGeC z5SN$AX3q!u9~Q~}kVKth18>lmLZ(c&o=}h?@+D7b<5TJ{17B*dgb++Y6stlpc`8mY z72r=-E?GGIY_wY;cS=(#EOnmxJ0xlq)f4S&xi-y#o|qxjUWU6#8&EyGm1u zp!(h0Nz}={(phw}YH8-RT;2SPtm@ruOM5xW;}2$so%u_JA!vWi`RSJl0o~Fz zJb31wH>T@9iI52Q1TQi3FP#sfz+IPxOY#?L4=(dRE0`M!eGO z2K51{u+de`PG=vTKm;IZZxc@KwN;itLCZHTrhO#$>DN+rPmkCa*7gRs{O#K;0lR0uk+^%?0 z;8GaRom0B22&KTDZ>t%Hof>*ZsB4QccTW^WKZz*Q$7g*=M#adpf1#<;WlG2%o) z5ljs5S_6vY=|m(`xsjg-J^0Mt z*4CTg?t)G)5@Z*GHI)keexzgCMH=nOi$sE{7I(S=MqnlH-$|!ywo{8f(W#n!$@C}N zNF>?XY)02y8Qc^mMHQ>d)4z~;_}(59Sgk~h4tbwa=kS)UFqV{CFYXYeJ`-hz=?5+o7z>#)kf= zQ;VhWvlP>zgd#8XyKqnV7=Ep1ylR(msmKwcVn}Ft&udf(TZlptJMbAe5&w^~(ANU# zTY@MTDfxqYMcProXNP-!F5;&Okb?FuN(P}u*c!3`lmuB>GC6k2rzd;U9*;AOv^>o9 zhY}eZK6;yRqqP48RLz(Bj2s%11a7N*gq`g2I^?%EqnQ7w$rN3}6-P+*#3LaO3 zKz_=r)bbT=e>Q!!lTME(F*R9MIzJzaE@t(%)Ia z>8RV=+uF4VK5}dv{Pf(9?lPkSS4XzEtt-Q8IMtHsB$?{$rL(XaV|7wGbZm88<^;vp0=xmIMe0UzSy zcg#PPe}0k!@>pJ4oWlin^9d{7XU71&%rnYv(^zO;*~VU+pRhDyRe>KZ34kG9Hq5Az zGw*Dt&cRwAMXIyi-p#g= z_Nde&69`Sh803Lw)>Ta}IBtEX1mfTutPA68#9kaGe7@l`ZoN`CtV-2md=hPic~j!B zrt2M&vTUs@*k6FTN77?x$`fFd&Su4842t=msKm4 z@(nhsrQdibR#iLSMu{sb26vLg_s@#*exiR>DT`i%0JUc1C66xwz_1FD&wD!{B*xFYppMABj1gpJ!~N&Yn3*Niy#s~G%SC@m zQpC{+I_CwS7xf?9bhX*W1`c1&l8xAg^^v`KnY&*Cu>dhPXZZ|gA?Kdlm0F2(qu~4;;m)zEq+IU^GcPa7iLjkSKu%KiLf>N`lKYY_#X#rvL-_jBjz1~tBZ&DWAi1# z!whlM;_Cw!5eNARsM$V?{qB&WCP*c^+;4^6b=h0<}EpV~TlpvP0h> zMt%vI)80O)U@c^44eLBl&m>O;x#d)AjlO!Hg5l=8QJz6o4J8)K>-e zp;kpir`vnE3c@JYu66Tnkd!K(1(G6?%=1(x%OfYW_p=8}HKlLS0OYG(!DbOz^Ypc= z_Wr8yK088PSmVK(XlqJu)~Ld&dTIIhkM8F&`)$hOWwObjT51;4;3+`y0nDf0xa54Dhpc=&3z*xPQOLfG%05372Rg z^`+}Ek1aqJRok#uH10Ncp=^2aCu}Qe=CaFS0^)(AzurLSnpEIbo;3QsY?+63d?P%R zZG=HIiyI^O7%1p1>D<>ZvxU7}yP&9Rc6aI%yQD2qGv8_WFhaYcE>VRDEiAc>`!;`W z56|x4)gKODpcPF3toSNFz?|b}Gp}nVvo=vQmUV_u}t0>{pzf4vtiXuY~st){IS9v0u&caOGUAC{8pT|O=1PrwApd4#0+fTk!JK45(-0R73m!KREOg*N1n$6w=g zDHih~dY;SDSl8pdM8Db16`k5=MS;s;jACdi5g*^+0hq=45z6mps6Lb=@y-N*21 z>ZC+$6E#2w-X2^)HXRk2qig*j19+( zcWj(cakbNmtz^8r5 z+q63{P_E4EAD_|DZ|IbGUF#}2NsS0Tp=ZmdIC)M2;tJu!kIZsIux{oQflh6_6&z}_hQOcCSZP1hboPXN(T%mP6XAwvfBozOeHrd$R>c?Y zEsUGd>U9S!WYk5ty5-uWtXTVdl7SP=!FVwi6mjIKTzX1_RV~() zzH464yam`uPP7o z_Xqng&-MFa%mDORu27y+3L~!asG_YW?B@)2=lpRPLw^HP0~CIEY^^yBHNAR&IO8u9 zJWa;4{s0_AkNLn(?=LMZaY>3KB*AQ~?DvVR5c;Hh-S197yp0e8Yyshq(1;noQHm@@Y6>98J~*=YNpUmKl=RY;HH@Gu`~^f zjspO)5idE+A=uL@k>1) zAAZoaq9<=zf)AaNE@0P2Re^W+wHJc9KZl+~y-4OhODn+6EN#@y8_nLH-HLjk;YN|H zUL_&RXdKDW$>-01e!361q1*a_y4wY32;2=&_b_O3xEI_n@<{5H@85YuQ5-m5i-Kehf^IAiB07U z5qZGV0jmO*F^GM-*J%XQL2Y;xT!+o3dM=^6Erj_BxyAbiw9-2H49A=Ufy*KN zrELwawB&vj*E7@7dqui76u*>N>&YAwL5kt@SxZ)-#35itE~X6A>Z(om@$k*? z(=_$UKWh|aT0tM^2sS?nZ~F^ZWy`^&U!o27*%Z%!)W78H^)jcdCUYQOadEl@0IZ#x zuGW;O(B6YxE{!<* zEebts@_=3RfB}XUd<7!c?>s84zL~G#=(Af_rQedcH}wdjwuS4PS|5cr&RumJN?}*8 zcwG2~+EX5F7`Piy+!K5yu6)8wVx^L!(a%0#pg&(u@|^qulk3gGe8qsK7!Kcia``P1 zG1n{y4MpMP9H2sP`tH3hAAAVZdXlvL9{8LuvE0lW^`S09{P+PcNlg^<=lqL;KCQcu zk4!^RWD0%Mliv38C_EXdN3Mj)&;>NX(ra%$qN{@K+^%f?e=mRoUz%+Pwv=(KSAafe ziJ>q-PU7fBXm!PHW&=gter}T5kAsu15z()Bsx1wozOrP#Ee~ZF2{Ep_c)p6;+tz{f ztd`h#it7Agl{C@?xs6L42U+P47J?k@VE=6ij@%S;z(p`?XTdIi0M6uqugL5+#7ZdM z1nI2qO3Q}x9`!F8g?A3bF#Zm)k#u+?>~fjHJ?7U`tyL_NDC?$U~Zq z0nB~UhNzj&bxZ&?kZ+eq-igpEK?*>g9#0q5;UB z)MNEKf5DG>Q?qppI?x?-AAkJ-x&EZ|(HKm>CKtF()-!*8B&1G3Fa6U0$~l1VtP&a+ z{0egUB=${9Tr>9~_Zu81vI)XWH)UgG%NP-h^lxLnfHO(A1{rBka;|k}-uDou$h`4@ ziBN?f5^tt==wEuZUToK@MVMrIZpVC$9%vjR8MRTwwKz<;ufsoEJ?6=aotrW=BTe^M zlA8s&%w>4aTRpJLpg;i~Bz@Yo=f4g6w`L+lo82F{aRQjTC_wmJ zpJ{CC$(KU<+THaPYTZ|yc$+BY5AxM(PYb)EIuMrq3Yh8&5nt+`=#1D^2h6CGoeQdi z(OaPKR2JEil85rRkaKBa8j0%ze5tegt2Hk=apDB}K68}B`TFwu#9oqT1zZJqzXS-Y z5_E2)4tslc#_e2$B7H?lA0-Aqyg07C8oe=}*QB?&l4L04JK;1LK;+BDIG~TDUn)SL zj1l69Nt%=FmjGsl^&4ejpwpv7=u#+s&fp zQQTpbD&s{T_`Z{>@doBBz#-dk?HsgrKkjpLSt`U&b9x;>i!l0Gfnt~fR&Yjc+&Rn_ zu-GvTt}3TWX}N0_<-OW3u@q!jso5v~2MR}V_1xcSiKA$mac~7{5ICNp4VtgcCSml&X?(bM;<7P z4x5LW@dM+qZzY(bk?kg!Q!RXv2MY%|^hBHINoNMgvCdM~xtdR=b9C@9$z+~-7BKZ{ zCDdEy&iN-PUZdQ}j}%Pwr3YMp|FQ2lf#?3lq&XWtA1_yTG7~dA>4O8GNj0Lunq>d| z_+%{nm$;j?FC&rWv7i=OXpE-4g8?aDa&gjxajhZOM(Z|Tzf~~z?3Xr+YiwX8e7R1E zGWFSH*J>G4a6lyh;o129z2r%)lHhZRuR`sQ+eT{H%BT#i5SmX%+Ve95~1k*|=S({5Vx0G~*#<+){Z!b@y74HGEM%kw-xxvBEA zt%TKJKN#xz`#8h$XbmHLUEoP!6|;wzjmTeh$+I3n0%DdB++U&C%q15w2iE_UtfcI7 zX_iSGwPmkx|C}fc-|9&)nTZ(;(W)Zwp4{mo+yJU{B+;U5PWdkhYIUML?HxMW75UJ4=zL$)%XUwF=H26f0{8EJ8lj~r~ zxc2l06DVrCc}L-BNJ&uWd*1C?&I-D!dWEn2?Hay@zVqD=?e_5WQLfNxuq%cE@FmtJ z-#6Un!6~?UuoWIcgXH(xw^ju%x$Ov6l3uC(D+aw%jEHrV0>cOZ4~vUslwGn8GxZmb zuI+1gdq|?3G17MDi)C0?0@P?;6iWA`E;XS5t8)(*w|=WW~y0+9(_d)_M$Xv z(4Zo+w<~ZzRTa3?q(pnA+;dA_2vpv*pR^uZU{&-UpaLAxP;*tw zMai}>YX+npe2bu-H62^JnaKhJn^P(??B%vkwp&(My|{|l6lmx*Dc0T`zy?kKxSwg5 z;-yM8XgLbW}NYWMeoLMJQ9fg z+4A7ek0Pvl2yoxHrJv?{OSE{vlbMBWora=2lQS~ha8;5MC+6(R6^az)Y}={lx5kV< z6$}iTWUOiN-_6QEo*kqLj?-QNF5yV^)0KFt0{yrNS1Fc_(g&0<0p%8hZvZo`>BNpbBVAx(WMTPxQ^_}q4=~T^ zpu2`}F2m-`+iqW<7a8ojn1+7P!Z3V?JsWrGy>@O{6FhqtBrEjDLY=rbi*;*YzRlQy z`PZi(F1kSAp;EYw5K0?RXTk$S+x=$S1^1R=`Zb+PuODnza2M}`;&p{SciKMkgn=zt zA3D>eyk%$!Pusq$f{OU>8XvDZwT|~V$?B6QbvTRURYiO7=@o=DLDz;@EEuSkc`hT7 z#A3$%8G?Iz$h8!j6#j9in%9_pjrHV6?aAq3OXapkdn%=^f^gW2qCFeM(96FFbe?$O((~*_N*v}r!>4?q)#vJ0AMSE1?SlqVH7=YF{DW3; zQrj6K%!cVO$*U2W>k*uYD9hmwKU-zG8b4e+AA$B~Z~oM)dZ-^%sE@QO+~Bkr#2)|@ zg)Xazf%$A@l-XYo_77v54!-9}jgccp?E`{9H#3>KT2$T*=;qpZozaO6{@es!y?owi z?DBm0FAHA{F$>HQx~R~^>m`+hezy1SI_ zZjjnY=`aALLs~+*M>iuBkq$wWEvEM%5>-zn(zu))S^WOJ)?sLv_ zs9`xv6>nD#x46!L@9^(Kz?z{=BEL+uuo7oDr~^zZ2UZU_g5~+ealre- z86feOnX7c=Cy)2$<_&It6`l3c-X?rxo7{S_`aVhjAAY}>22aAPVk_uEh28e`aDE)O zyQuh{s6mJt13oq1s7@~%_FYa)XABoHy+u(7+LJrCng%%3Y=7|Fs_AFK<$cRhKNZ5K z@S=e5IVP$n52^}zB;sMk@+K|soVW-cbS<}5U=N1^j{il5KEz1`pXDH>HWlThu*P|3 zueP`$LH9>$gqHYq-8IisI^9Dn+}cijov~`?nmsvFvmS-__Tt4p>+-cMYp^0*mEz?k zOSskilDjn$ky&_1D@67Bp&_#@qR&b^c$L@TIe7zzQaxpL>D8IpH`@Apm3*iyU&|;# ztuce@tItYna?1-SGV*c_tOBC06J}E4#Hen-QN|Yrg7?K6uTUiFaB;!lsTIY|kT)GQ z@Hb?nALn1bIo5lji#026wEbK*^e>%;fBq4QQ*@%5+^k_aBF3M>N}B_e>7SLNBtdYV zl^p?~dxvw6U=&g}?p_j|e=9Av;UiY1ud*vUS4Mt)g^L3%E*$<9%@Oj!9+S_Gu~uwH z#MfuwC2qt>T!?Zh(Oe2=_MAgL(b3%!Pz?dQM6`Y~)A)C@E*uAFO)9e7=;j6k$Y5K} zu?BN{Yu`xS2|LG`b3&s~5Ra24j=dh2@JyBTfGBfo z3C#>VXhK4F!nUMEdd)H|2hTDJ&^>3D)y+Xb#k(UTzLtxxq=vC6*rkldSnQ?yU!1QH~MoOG? z7MgIrZ`fk#fDJ9^5cpurgsrHSy81h)4<^l;p!^d0!Sp`6<|`54z?e=PF{#z}KoAx& z=KHTu!{gCE`n1kh>us6BEpMJkKJC_ra7^9cbkbP4V2`YMYi}&0S-%=^Tyhe98*?P+ zwnJElzZ2m3uXD@%i>p&Q zyJ7g;-KMefVi*mx5*t3rrQdqz+}+8SVptWwWhy<{JECV%114IG{FP2(5bZ&KMIr{8 zE=lY6t?QF611QSB_uIS8xiSz&9s#1q4~IYesMZL%UHu>IHKk(lDu$T@)1$tFZ zTujM!R)Q%a?sFtwxGvr%5ue>KEd1IsKE7YyLMuMC;wc2eq3pGcmS)J?OGr6;isJ9j z1jP!+GPGR1jKB)IV_m2|pF4mNh@oYa^6^EM*UmD(Cg(tTYi$%Hfx{oyURy~Mh6 zXbU4q{*bLf|2*d$v`k(A9!xLLU@uZnjuG)AUppdmVy=5;B976d@&GfC0ijKZI3wuH z1k7EuF%o5#ZI#SO)I{IUO5Fc+Y|D~s%k$@=4X483N3m2u+Jy0k0#iG(AQQXC?+i96 zzX|MiZ1*p%z@Gx=Yw-6Io$?HHd)Ii5iw9i25(OA!hDl63DEwC~pPbL=XB-E!u0QLJ zsqJcd?WbBP+dH;ce@!gg=}%zIXB94uLxZ_5eL#Vv?XE$?f9cbB$WhXxNadJ{!l024 z$0s)h(b+=`ohWubjQ(ocus@gR^#gZf5$W~~Exj~E^@i-X%HjB{Ei+~8j>#vJQE3-} z5b`O2Nfu&d0i`oQN_hH}a)*u;ogjOi%04rTIJxRQ#VtlE^E~vN^81CJo>|W0jQ|j^ zqYU@U9QTN-qaJU=Z$8OD2`jAmt2_11_VS`@j46OhBmpQ!8qox-#y*aKC4_4WwbM*? zNcFrWtOBPp)`B^dwO0<*J+OPO^4|UXf$>YVO{isd50J-UQNyyTUAjUGT<%_Z!do5- z!|6$MLbSfi5Pr3kd;Itj6lF8B{0Kb%^(gNH1d9Cl1V|9TJdDdzdikI^#0sC^r@TLr-iJ;4n-3w$#_{Y+)=+!X) z(9$ve(97$vGK%s}i-T8C5T5hh!d6Eim);Z1VN}-ue_!o!d(Lq@`@~u>NQP}$CwlJg zwLNa!)cyMqQp1L$P-sA6jI3);7#uR&`-9XK>PZ!0F};e z<-i`ilp3({BD)~!wr|=!vBiaJvIE7cIdOmn%`ev)uJFNh0*ZY|E5Uo%7)vb}`iYh0 zYT1?3Ku;TWy@X`Pdj_|tw5tSM&%R#+_-IV>$4~SiazESo+4NiDcer!vi7u(2f_X^S z>pEo}d3;wC@}rT*){1Gdf2#_hb3{~ZgS6YVYR%IF5-Ys*n)0KBR;&<1kKwJ2=XPhdzn?xYz)pni zo|97OY{uiu$-{D!d0z;+h<4DE%n>JAl$J=1XtYq8Hz21tFTUj!d4L> z|LX4{1bql`g!2EIs~%p&R#)Po@%8H6pnmAL>b6^Tp6-^|y>R7@FUJHPE6U+bwV70x zSvZBWyx0@aa-&(A{HM%8+1YY_yvKlit|NdqiCOdytO=?@J2I76w^*Llc5Z##CMdUg zs>?;%7^lc%WX?f1Hyv8zq2jcBg~o-XRiOdW6U-BJ+rHt>Uwi+-0-M}a?Y)LQ9Sd#} zD0Pg`u~ZCLNdTi=ET4Q=d6#-kt4^YAx+KGLfISL*a==;W1;}zQ4)q#{4@?(F6PId zoYl%FPHAqi`)YK`vLE&kZA|f_CW@YQF`H#UL=Dj;5EB2pz|p24`J#$@!A?i+)*5S> zY~r#YtgCbv7jkXfcHf3GTcmx){|-}Wep=!uVSngM{OLnyppfHI}yH!teoE52~NL zyQb4wOYyaom6K|O#-Qq~hOc?Cw-|c~ez?3xoEJgkx_&l6K->}^=rx-N4?#l9Td>wL zfr-r%Q0eP`xP<}?z+X?S=z-O&wOxwxk0+!+%1g9~9!ytKsW_*1yfLRe^{PO5e8c+l z#6PvM8M8?Z=B%YZn_VC5`TF!zZv-R+_kDhR7N!T_YeYS&HNhtg%~fV6kSA#APL8BN zNq#7UJsgDi$nY#)O zG#JgKqSfdthe{$WADEIQX_KP?IngdE)LqvAtH*v3&res*9~x3}F>820Xx<4P?D;(J z$T^Q+L9XeB>^B~{{j+%#0J&`yBdG@J^OJEYWsM&q!dH<6mhl*5gG6ds@i@9NgRc(+ zUw5PCNtOP2aXMXjM!3_b!y8CbszPejaBlClU489?R0;sMyxo;OjWyn$@33{QL@I_o8CNBd)ObSJr3AnxSX3)i1b} zd!^IQwVK=_?#M?Qeg?~}%zw?ty*VDQbNAHf^jXF;saT#yxGiQl)3#)Ecb-tUjIHB` zV$Q8WVlN!G4qb}m!ieX+7k--u?|f)vtny8nbOCaKT<1n$ncqn_2P5TQiAXI0Wr&wU zmYJBoL{nO`K}!lp4UX%MAxgxZ8Jh=O&SaUeqKQuqma4@4WHERzo~w0~f?{NGF-CbY zA=ux&kDq?S?_?rT+SOeexcGnJo@Y$`tA58FZen&3EK+Pm&zZF+Du%#_XER>FdVi21 z*^P^Bl6H4bXQ-Zla#+i~$Ojyg&99M7;Jl(wq~Bl( zhQ)&&_Txwm6D#=qRm0=3k+Lw_HMwjCT-VFkl=29}xxc$|$kMOqrUUo41=yz@T{j=3 zAU6T-CBTDb-~H>UX4V&REkI^8ngwqg@S1Sj6?W3m^7AXAzX%Y5&TAO98>`O$Q&t^h3(asg^&)qj_(C@z5dpI z)fmMUc;F7dv5&+OOnidHP!1Sss@yTJ##WYwTGR9vqAI;R>or)2d@(UrTS>8PGeS@4 z7REOjNRK>tHljV9-*!I7GEas3hiM=FjhwT#ex6g>ER8Lo!%t-adRaiSjXGNM6Fkk8 zvtrhco$vb6fYtWHp!0UzW0^mz#Vns7Yp0a?@BusA6A;^OfGh~mj(v!dk5E<wS;|t#o#R*(=vRiQ$a1!U$@xLa<-xIiB9`w7WA+m`2O>RgS6t@NV zrHk+iS@mrBa{lNy(}PGz^V5UUFAu3i1AjoeTP$>Zc;{8R?}{z>^iV;1FJ~hjIW5Li z)s_UuaDCXY;C&S5jRC?_+>g);!#SpFozVIp^!rd5^qW9kY|iR}^Y^B}aJ z6F>{pxt)9Bo*bY$W#ICov6Di7y`+1Q!s3ooh;n@ZW-86`^%t8eQGFh|Ey!xufrtAO z_FT@c1Q!4NoYT_dAjBlqslv_yGY(4Pm7NG2^;WD06Mzn^Z^No{)0lh)5p|LVt>#IP zVfsSyKAYv3)q=5|3X3`DBy?A7v4&%o@YZFwyRu)K9u@Lo24^QM=<8dKD1R39?TRma}j-PIe_ zxCcR*U6?CepuyG5KR&o)U7i3|E;!g8{WjK=<@n(>8{Ku{9lF{ znDGxZ&Vf)wTIsRe!rC94OLZ>g3A{pc9_8KVWn(b9wVy@gbLuG(!)c7Q%Ka%qlCReA zoL27)UQNtHu$i>Em0Fsr4Xeoq#t1dU&u`B6i9;jEn5!@JsY4ab#TggB#Xe~7D{9S1 zC9;u31Pv{K<0$VaEcdHUVRE#8+S+PI5KEG@Rqsg=$6H&!dq-kSI=41ts?el92YNxw ziQXMAvh-gSWA$~eYh+!$Xz>f3qR0D?TVhSeWA_0Zk0Cxo@A}?}vlj_t;I$#NnsCjR zp5YmuD=d+ySXjO2JEA%{n2m68_G{c1BceCFtmlJqt;cXc#A;y?`Sq4x#aar)T;X(p z&FTW8O^LSQxs|z~93*{crzs10zcAK{vS>Pbh$UYmTk}1Lja+f%_ z6?!cb1ULA9EdVxl$IPY}d8K4Nzv|NtUd`I2;^IhbVbIE59cMEM8$1(O$_4^^^VH{3!lwbv=JAV?`pTB%@uX-GiTtG5Gg=~Xgy;BdjBVLgv5kxuA0vR0ag%E zV~yXnZLl*&4U}!0w60GZ8l31<#PJbK1^WGy8MV#rA8@T`#+@=+lZ9!GccjET%IwD? z`xgVvXB!_a4Y%^b=&2TQbg5csCMt7ru~VVxiq6}Db3C?$4(8W^9DOO{|s zmXHCMWo6L3&j74ofMEzE7Q$-De;cSTkJV}I%}A~4DqAbVjR5g?5wA$NQ11jy=uHr4 z0C3=6m0UUU1VCg8Y0|45g=XQD3tuWyc1rP1u5;TwL&QD7&vdTRxkj22;_?Glq*Hwv z8?pb^EvPi)5Gm(*998_H4*)aq^4hmj)kE2bsmX?b?w+Ou8gUq+r;nPaF2eHLcJb?S zj}E{Y8&AK}GfR_R6_tYe_jlA$=kOYu7F=i~J+`FI0Q^TyLc#69neTC&Sh+RZ4DL{_ zHk2z-jmhsmE_FvUERXkLPvcbsPGyTb;tVK>S}q9}smQV3+cTua4|&(xKWD-BK+L>Q zVh9s;!-mz&>iM^mG8q|8l`)qo-fb0p`C&DCxJbG`SJ#kg$%_HoSK9^!OgKIkw4RwdA!^kXfqdf1vX<#NO-^`l2N!aaVucV_Hwj ze)3oX$bsu%0qJi*4&qg`G*&#>W89;kN1{HxhTL7B#nR3C$x6!suWCo$gG#gA!9$W$ zcrX5*yPJV-8jl8}zLRUTeI=9L{TeuD5roYFe8)ogWBNL_GE{xqb`Q(JwU;nN@Y;gw zvSlPL?$0=40O3%mjwcvI_<=(=d+kpvdE`HK#U`&s#bE&}%CPw12Q2zRbw&mqde~Ti zyl~-Q{Q)CIj31YZ`l11eW|VFi+EAqoPd)ra&Aw*1+2xg+8Gu%V11pLl9IKO^Y8vpK zD2JJenZooW1nrEBfiau7!`dlaWmIMfy!8fM}d@p zqq_(9&(mzv@?&=#^D_n(sUjOd9(KIUHTX4=d#SqB;hHv7*}slqssuk$Mb4Y5?k!F* zs58+iM-&ZeV70jZR8ru=mD^jraer1akNWM;<LUev1_F@%4%px=+8;Tt*bmjYpexxWLrtD&^l)8lQ}T0%oo zzGz=nrK<;=VX|e9(4JB(f*>2(w7rd_=%hKbucaSDQK8(-cg6Xql8y=O?ZY6lJ|rFS z?<%GNti70dGRhG+!$Y9F)?=@Que(ckn^;(v5(=}lLa%idQHgcOJ)z78L7oWs?E|Cr z8>tJJN-{;+JgdvUI`<@pqnsCtcK3cUYRHAwIle-@2UN)67UKNWW!evMrGH(IF7P0h zkw8#j07}q(ojpOD!gf`&Z$7U3;N0B@#z194SL&BrNM~fEawt^S`BZYf!2S=d+MIs3 zj=wzn64ye&_QL0bzc%zOc&&G}fkBa0Etp(f>|QSM!oEj9O8wV%9ICB<`>n4o$>AS9 zFkIWDzOsyy?@d7t>yCu`A(pWVjTQ8n1-w2YI2>uPp1IU6|fM^J24fkB{He53!BU5#D8Ow)uzG0qg=S|@wdHvGa8Z8ynZ@(Q0uo`d+0t2m7Ez2HNh8PMtj zwpcLtYI-|?-$r8es9PHK#yQxH>sdq3rc)?Wx`R~bOFb+JWJcP<=PHl=-w|3?w`-FwV?2c)aT0@27B1o_m7Mx?`>7NuIYYSXhT_D&`U32`DQG5#^cmMC+vH~G4t8;b{96j z&65{)*KijO9TkJhsCNp4RooHs+^;Tle>FBoOQ$J8tN+Gxt|6&+R87!<{Rl2EiuYhS zB-!T>ECN^JW#6NbX#H)}-$Uf!_4SMKY>6U3d6s=l4)`TQCGV*&qg1}k0i4J8o5Ix+ z?X#Qeh=`7)iEke&q*QuZaW3~NwyWF#e+*w7$zIDa+&}VLeUleHq7e2Ji(pz{y3pr1g5^M~C-j7Gqu>f^Wh#U;ibX;{_8BC7 zcDue2(e8Hj3^~hG;kAw?6wXtjIi72F_V^9S|1+*VK}j5P9VBoQzY{MSz&UB5Ged~e zK2vf)<$$YWba)MHU1Es-7S?ju>w;sq+P{wfi~>eue>yq><%RynL4!ACO9`6E-2xvX zT`<@3lfo(h3?nuzgA%uscCjkg=_lhHq1JSyUVCT3k!4C6*}N!(y87;U%K?%(M_b^w zN0!fdI_qRNigNsg-*^C>_noQZzo&18*%ppeR+^3jCW&0s^%Wd~EG)B7;fNj#x1$xZXDYlle5Gc9 zUJrV>U+0FP@BY&_reM!>&YozUwujw2_#9j`4AkNZONd5AH@2p+PaL&@cJ&HegF!Z5 znH*(;tT7CLgkdk8VgxG}UM!oyKRGu7)dCAue24N_b#4g`U29OS%?6GEDT@{Vrc!;2F!jz0w`5%|m8KrB?prKXCTXw$#$qt}nr`Lyo;6alTL)tONH z4X*v5L53|r4z5YtiVqW`8-V0vo#8IyDCPfVo;%CLVZ*jpJya*Id#FujyM4xz7$)L> z)^Cstms$1q*?a5d4ItGy+x`<%nHPP+vC!d16nXvD03L;wd)#0dgkBq$CNdB?KMVtobWF?b&esmf2_qslxFI)x z*U%|EEkR@hIN>}kIrmD4%chTj%eIKZ3-UfLvx#4ZCvLq)Gy|LwVrJ!ssW8P)igTwl zy>*_ny)|&1`oFBPkM(KF*P~PVLKkTq0e>jtck%tT8Js7cm1Hms;m-mbtfX`tx$}Za zQtP?MT@wcYQXo~l{d1b`RU<-1fY3_f8zPUH{x$U(lW*8VZLi`Mw_;eK@-EP9Qcm82 zu(4C&mz)CQPjHKzSsQOJFcE0jSibE|$GVu3|E-Wi$3J_=6);ct0u^OR&<#^nwxmkL zhe+`A#ckV+P&+a_XF8fN#(Q*2;5uW)8BViN8|Kb&hS}mYC@!i+6_|`)4{J zc(%I{K~;u|PW0q#%1nSPc{IZ{05oV*hs6pK$Szf&ZlN`0@#}cQ8Iy~o`6KCm0$&92 zJ~_tic^s>TFq&-*4PIoSh;+)q9hZ3)a$XWv257niwyBDe32W33tr*T=!Hfv#eGOpG8`K%8*xz`pqk%I-TB?VBhoIOK$AW(ty)4X{%yF(VYd zjU#&Tvo(39Cj6LRV87pFzx8T{&a-Cs%VDwLJr^vNpg{>26V5j0`o5c`FkuA5^Oq}< zm6DCAC0nG2$yVJi-hadtsdd!GQ9CFQn)A$IeE*&H$kJ@UnZw*kS#gNCMzFkLEtLeb zQ_yM*?BVT{q187W?XiZAdkGXfkSyT|H&&oH>mQx2BUk@A_0W5j&dM({iy+V0{;zv( zEy@1ejN$IZgm>!MrzcM~o9^~7vN_`H-(RK|ehg3J3jHwB&3z}rYT`%{&0*r4Bq*)! z?+_J=>HvCZz*td#=PcHNj%!tlw@(a+#ZiUq)8gX_HB0_$y3-dSWJZ1}XZ$ZiPchPp zkM}TZC{-JgZz58q@}!Rz{Zn?UE%B>WQ7qtf(xfx&34tsUp!7&$0Z6N|Z;sRCTv(6| zn>z+iIMc<)VTv^Bk;ifxOHpeyTYMT_3zOB~Hy(x-QL-))ufhqX9vCz51n&ZG5`1>Y z{*rb(uV6`9uZDiZ>hr-f)A(%*5dOv?xW9k76UA099e4U>KL`>a{mB@SH07hNA7nt$*TIX4v!2ht+N8}!O>{R4pe-wV zCus{;13;Wh^0l4J^t&Yp_!{N0eJ?%AP6w4qH6Rdhg3fNY1kANnswW>BV<<#1k!y#H zQ#*xYy+7Tunp?*Hu^puYtFh`CBbCcuwe&43zkW_9ed3;wt&Dm^L-eO>tMX*0`MAS0 zTGY0xmHqUuDO?5AYT|79k@ptki9|Av)?wy>22DPDUIcL{iIby+G}`~wYQ(U*)$CXi z3@?~W2#RjJJ7}JvMehzTyN8mKopfSXO-s;Ld0ISP7Q?w8?M9PGdVB*u*4L@PTBmFL zG$X=8evFmq!P8o5UB8_6w4m6nHQ@W}Mh{8dhYF}5Y_cMH0x*#r_lkee62P92CR|I< z0wPtEurvpUf~QF3u_BVasrYDO$@_%(t~FSuIl8~Itl!|kj*xFb6Lw6;I$9>$pF5C) zSf&w!%ymwR-=gkNnH&$EX9ZdU0l2Q+zEYiC)$Q*^+dqQYk>Y2IjZ8ESM(aN$#w970 zR!9&IUny{>!j{*LF0YrIY7MOK!nUjn8DVTcP9)oiC>0${V)IXb$07(OC*m_MBe{-) zefkfsK%WtrFSsjaT{$_N$TbaSTf5YoAr#El_qNDRoG&+p;rZ zL59(gLqY$)-dKg23=0I>eFzL8zPiFYWmY^A^&l+ZZ&`y_{VRe6DwJB>UARmHR}A~E zM?)}ZaQx_O|KAUno>KhFc_1gDma1jqJc*Rmw+=bNa9lc2&`m=tq*&{dq~ zx?Ry$pRBzIf|V?@N=Jl*4pl@OR+)phN!}T9n^-x3ab^=<(e|b+;Vxa{=LG%jAL~b0 z8R2qnG9SBXMJN~Y@X&h~k?Gv#J9J)y^`?;Ls)~1X^l3HhW=~9ti&yA~q>JQ&h=e8( zKcB*%i<8P3nFnm>?$-U#c1-AxNC^vC*b!ED4Eq#wg}-arF-8>D6Nqdd)ZIN1chrJPw@lyei*VibP4%juATiR zXqsnz;(0mSN`f&i#vE^FqyU9!j9>p z*8)jdSf-F8`f<)`jd0_RNO4RCtMeSSaCOUYf1)??`s;2~#yGa%ISTskwHiNw17FDW zd&(c2MB24LD^3MM^&WT6gUOe7e!Vli`yDy*ho znYi=@}cYnE1srh4{E7JptkpO0MiE%M9(+<7ro<; zaR!K~gJBnj1xST&%(?9F7qp?vksPxP>CcD{Aezw@(vcpK5pcH*4X&BwuIJMdOF_sr zE47gLe?ddue3x<4(+nkz6{Vj2`w1aTF%GAUubAOmZ&S#LBXUk{nYUFk+slsOJUhrm zOcD5;ANF9|vi4N-&`HkkfZ@I68%#mJZ<`IknhNK+=Jxpg?_$*b0i?6uFteQ>53v1% z-`c;fbig^$Zw4l|glS^05j8>Qcx13xL*7D)hzFE96ZuZDosUt0{YND!vWF5rJ-U9+ zx}LsTX>Bsq(25H}Mz71XXbBcY6fVxkNdwxj>RqE(J&+)HIQ-UUx0YT1MF@+bD%c9I zwy*A(C48g^JWbU|7eJb5^qdaorg%Z8?uuQm&zRn(Ga)REmofpQd7ymR&VD4B6;6`a z?qh_=1rN}j<&GHZft^y>0r@}NxqK=4D;Z(x1WQ}scICdOCxNz1sGuIC!;gjy-|KBa zeCxeZPXY&TZ(E!SsY1-3Cs&IQ+7>28$BzgS`D?@USlJN59br*B!}^?sC6U$}+a%&L9uaY7*%zXMSclQI{a7Mr_scXhsPt{yuU4Ns~3nSKu=NFcOF%t#0k z!<+j6eF#eaVrTrmPjC63JZVi4=#*dUzZMGVRWBs|6bUaZm>>;3cHbqR*wN4R2HfBk zK4*Rh=Fo>x-+lL^b#0W1EsWhDKc^d*4qITPbRxmBXgRSl7ySiQz^Kr{9yk~}ipImk zRO_U|Mad{`Q#nhwwpWJqbv&(dca}*D4FNVMO}5DzE)3#vG1FX5sV|#2NyWIVA@itg zT&YK^P%O^zD5J=_7Gsv`buN|^rW_F3)GmQ!#(T~>?J z;^1ZrT2Z(cXH~fL51w_=mS(8Etgyq*HAYbS>MS9uqxBB)=A2R)3(}B0(T6S(ASI_w z-|Uhtqx0!HvnIOl_!3D!{%Y5mC0y}-Kv`ryTw;*BAQCa|HQl&^jAcP_X4lmaDjuFm z(tCrb?j?_<`>OJjj7;|ti(#q7X%Fw>YKv zK$n^0))&RIS3ZX~AAe)}(iLLylsFe%B1S7?er$N~a}W=4vASJoh5;!x_j7PZsXIN` zbG+l2K!$HQlseX~6afiWJGJ=P?BX-fx5V=UH^d*XO|0-(O)6)wjqKxGK;E2Y zKS`3}@_TI}DY>q@RqC?6#(P+d+g{~)Zes6tMq(JUm8EYaKiA3CIKEMbmm7Vappo$I zGWUII2a48tUxzQ14pi5KlG%MnR02JmLf$+++bcUse={e36kKMJN*pJ!s@Ajr@fSqb zKChCIAu*UL8BCr-a(@#;B-}G@Ith{Pyzc3;p#5}X8+7^NKWC@jlM6GS8(epThN8Lg z*i;^lwW1qJDf>AOYfnpMG5v{EVRIE?1}QrpVBa7!`QxZ_@~>SrYVUE4A>1(DX$#6P z&y{|KDAI%q9FHD`K0gmy@Jh+J6<9~s9%e2aOm?C2X9^zXFd52taah%e7?PuB?8`q= zKC3!L`JvApAZV<^t0JYbt2@ky3nNvu^`vv{igqWF07I@XA;C`-j+fUu>lI-p<1#;_ zc)hIJgqGt!5aTGR;!D$t)ptQH=!$Xw1r-*sC7pD`U9k8CRW+KiMgg06%Ug%UQq>r| zQr5cJYTC8`hF23$8Xyb%-aGe5fc=k5HNMd&nj0dcHGdFpY;+fx(ael zJ#LTrUGYiMO>*tUqhmQW4Q9>IiWNV3zomB(7y3^Qu<+_-=?LT<&sQ9HyD*sNEZcX; z-SmmGaL#2rL+MJ^J=cj?rqpa3eeFqGHPG>r?uE-E1!OHNBRCAfRS2Rr501XYNJNjB z-P>+9mHi*!V>RIa{+b8>S&;j|Bdn?M9S_m_Uj4A!v%DOo!>E0ST%I(lXbweziA??p zk%^8B;@2TfJogGqHghLfxADeg4fdUjI;^!Z{O9feP~J+j z6!{BLr&LVGm###r_bMk-REq!iD*&6nzu`ZB+od%W*&pu{ooBS-auN0OgBZ!?xMkU4 zm{oPQq)STc>JTG~&<$IvRDKzW?6rm@{U3t%WqZ2j{%2r88e4%*9-k8XaEx$fr8ApQZG4=;PGGq)eu(6S|~Gk&x1u(SLNuwsiU) zD2c39xXdY7SzZXed|{O=7yIm(3HxTTSS{N>cCz(@L5p$af=Zx8LPGU-_ZP5w`IkI30wWk$M+5(9`AKFMdSa&srUN;YMK{sO*K?{3ta9nvhHI z8uVpXod3uZ)N`VsuDu7mT4#*lpXf9T6+DQm7*_sb=Z{sAbY@PpW`rX5Z@*m`*MhN5A;Y z*DravZt#y0m4^gAH`LOpFAoLqe*L)0e>zhl(k=`p#eShz_+wJmW@fJmpT=qR*|G$} zj#v_MR!AQBsLVTedP}k6(E~b)_mY1(^!(_l`1vg5YJK1+*IAHR4O56Wxy6q5=XX>~`eJ$+D>{)g_pzt1I>qq1rB+&jE3Rm2#eTG;?C>CH$pR znQ)0>PZv;*rPqB{Bw|yPE3HpEaN}s#IH1?FS6=5Ik(4BrmSC;0J!{yL(&tyj+ff0q zu9d!rw7Xx}J9E^E)R}L9`rqS9 z55>ULyD-5geU%9cJMIuLUdZ-b>~{Ytw-NfB8GTY<462tD4CDOYS2E6o?l%i(oxmH3 z5-Co$MBhq>ogK5E;gj@J0~v^{l{9oBtfuTL=erApcGDr9)=RQ?3xVBLra`KqN+kzJ zmN`^Axmb)*QLgUmWR5XmIRv`;XsRY8Fczp2!j(m6jxV?S*G?q~GfkY3ord`X#Y64^~A-OY=O)n_5gdN2pFm>yD&md5561*k1Zrzm8YrWEhwsdxNKe=0> zf>UUiz4Yf4()1a#rLiM+9vPn|=?sdZ30`RTh_l4a1Yq z>6k)X<68+EuZ+#*wDSTEOWzku2Ow0Yw>vZ?eykml&kEyh*w*#`B3`-Z^^lqjvvuKJ z$!ryJ{raCq6(g0`q3wTH9UHh})T(kn>ULL256P zhoMmnOM)4=b%Q8hk_%NcUyr(n-Nj>uoFBg<<@ZQf0(EEIJlJIysZx&lH>$;Pn)9~< z#cdMqf=?VZRoKG8b1{XZA^3sfZaBP-aId9flNV(9)!xn#*2Dx1q43}BlrvF-xDoTq zqEIP$?YxksHy8u{t3}_tkWNt6mrbB*3O4niXN5J^EMB*i)Xv&xl4$BL9L~81PxF&ABxTK(k)xmoW=fimS@yu9>kl$@padur!9-)FC1PfP zyV;O3QUka|D@AmLgy=6~sUk?b^B+dMgm6^KrkskAUaJz4??%A43d=~$PURV&j<)!D z;H0RIsEaeGq4nD;JL~fCq=6$ zrGZVukjwGAkf&H?m4^QJvTgr3GxlNp8uxxp*`YLL)>(hWno^p_pC=z-vz-^azZj2R z_$>t>TCbM+r&VBFBiIjJ5!l0vswwI+CY2Z8tk|hHUaBX6aNAQ?i;hAU>jgDKpVduiUhAKn0YMA!`IhlJC$ zNb4SGtS1%vXAZ36hIUl{ZnSLZS|er^yB9=j^z(8`<{rN{+!gN@&I)cZAf`bP2e6vY-mh2pk-V0(CWW6VARsn~GYSEEtz(X0Rg zy7foIhQm+GzuqxxiM6(KF+uwt9nmtTUv9TMqLYz~Ikdq#Z}<)ws<{X9nUIdmZ(M#* zE|vjzFCh)@-Rc>|{hc{NCaH!rR8BU_?(Ip%w@)%cjJ;gH0UaCZ=8;tQSxj3pw;k8w z%X-X)rF9`ekt)a;$lY-)WH^58VVDNr57NLmp>`KUKW>I^F>$y79YI zSSFk7E;(ah1Dj;MX78t0wk$~EBas1PkmJpJsSYd;y{Nh^hHb3rJyxeN6~};^;`;>) z%Za2#(Z8(Xr>u(hL$V^$rY|H_7q^le431KQyj-a!oHKoQHtiYX2y|8sW8C~jKG9YL zDnH#RY0~>OgZ*l5b=;bodh?`Za8W{tW-;!GnR|zNxmd2wloSA^WajP8C)B!RgPn^W zB`PjyzY1zo6F%Fc^?-Q6b&|ND+`%M@!f^w*3(13bIv~5X&5s4>fqt6q&tU?U`KRaY z|9jONkSpG=`^)AU%d75>h|`mPX3O*4--TcMBcf{$k8L6eIA{bL@xS}_E; z3tvd4KI#o-5^nuO`9qCw;cE9PfLN2%G{=PMGq-`g{}+Y$ViSCOxFuo^$T@6m>1SSqZtVlvv0~>@&~zO)`ZPS2>28kJ(*J~EZU;VO`Q$%j zetP!cj1#l}ul=s@BjT(b_q%Jz;qdZ2!i$0fq4vQ|WO4`mJTEE%k0#ztOW@H_f*E^V z1?Y&oGXOA}`Qnj8G_6vB3BJrThM?iZ4{>teKb;Bw0W?lU|EuC%1?C<+dcU-l;8(Jd zLATedDe~N#?eE#4gAKC6i7Y{#Uqz9=0jH3&C-?q{Eiv*pe;dO-L+_JQ?9mtI8V|qf z8x>G80=CRZn~8Be6MSMOBX$YLQ$^eZSa3M4lzYJL1EyVUfcy^r<>j3Hw*@h`(HW{3 z-1iAv=G|bfqWvW9zLYMU>-+1*{Sx+AIB<)!&j%<0*07qw1msf0cGA{AP%*>wzv-&r zct=l?VMkLX8^D-oIi{Aeo8KhL@L#?il=B$X_&FXz>K5}tKd|q033(x7bv(YwMT&;Q z8_z?BT^|m4{mEfRK-PZ!7z3`uuU^7{Nk$FPi*eWZZ1b2}V|WmV&9cDiP`V0eP*Lyz z=T9Ps9T&3O0P9JYk6O=?8pgt$y1GjVErU-gzMyuE5!IVDu>b9`!EvTvURJ&E(DSt( z=szDcJACYJ8lSu1MEreF(GiLamgxBg79}kJ!#`NLI{yW=d2IE?g!$zN_l|5vxumiL z%nRpP0FLkJOuAIFn8qYf(?{?1h=}q#l7wJBha%}V!#c>*66#~lM_+%JiE8b9ZgO<1 zHF2>1$%rfUb8~BJG{NpdoHXWAX055loYnJNe-laIp{=?S@_G=WaPqzZax+kJc64|C z1tLfJP|beo;Qzl;xxNJnc|buzB@WLluUGpBX@xCAE(1Q^AN2C#c3?)OFKCIv=RIH_ z(q1_9BzBh-IuU%2{FkiUnT~Eb*IAHh8(&R{PJA4@V`{CDj@;>gD^#%OwGGrodltCJ z`4Ex}rWM83_Ic(X!hc4*As8x)UgvI} z`#}7_Wy{ik=i3|o%8qM{gnF(3;)M}D{&`gaEqX!ovrHJw()RF*x-djpT_VonJCl}>;2y%b*4lqy{73$Nn-UK zz7VfA+bB6>gQ@7*`G1$6ZBl*CJuRV~Q_Za=lu^-U+NkWxfQj_pC2x)f!Se&f%~BRZ z1l&gIoqC1x_MaFCk7;kYHPK`ja>gp+B&UXRF+^B*5nbq@C%3ln#Bclux=}Z$`2sbH zygw+s|C{;0)x8NAFTgys_@c`nSDF6{ECSR0{LVY?yYh^)E}XaT{)hg_$T54p1ls#w zjVLszF~Cd%WT=jll<8R!xr|D_VL==UM{&2eFRW$iCl_dg)V~avCt3i=zO)(+5VrS zdnbHk+JT2Z4u$@TTW_0v*v5@p+W9jlKl#Z|e)99aJs_mw3ogGF z9Qo!>nR?LEqsQ+3`Oe0eZxzeQj{*MPJq#KJB)H+;y8wtn2~y`!$6lpGywN74W}IF( zpnMUVhS3vLH@wxi3FH*$41}zi5P2Wy=7N8&yN%NKW1A4t&?BJ{cnqBlaQlPr?@WYX z?J=SV&Wjy&c&zo^BMS8&!(;sxpdFtFhr*{P?Q_s$haGXkwdY-M=}8dtJZaIA6}gRW ze)5x_{NyJ;1D*BjHxGJj?!x4@JLXKj_|mJ6I_|_X&OY$qBd*$e(zHAK#!a5v9@)Pd z!iAoNDB3?QmE$kK%HhRA)O-mVhwnnu@O>aE{sJPDUv;+;op0jwO;b3c4{(F2{7v}$ zSMdhT#;brx{CsGk>-)ptK86<>FF|2{5!M%9=!lws3T*plf$y*y_Vsgn`zGEoY0C5~ zXB=|$*(aTR)=?K literal 0 HcmV?d00001 diff --git a/dashboard/public/tracebox-mark.png b/dashboard/public/tracebox-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..a51faf2ebda4bd15da9e18b19080e243b6844d26 GIT binary patch literal 31586 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�rIeAI+hY)uXmZ7E^Sr%M^u^2nQ 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 literal 0 HcmV?d00001 From 767d96b6f36eebc3927edb3ed67fa45b13ea1d4b Mon Sep 17 00:00:00 2001 From: denis Date: Sun, 30 Aug 2026 17:33:38 +0300 Subject: [PATCH 06/13] =?UTF-8?q?chore(dashboard):=20next=20dev=20taraf?= =?UTF-8?q?=C4=B1ndan=20=C3=BCretilen=20agent=20dosyalar=C4=B1=20kapat?= =?UTF-8?q?=C4=B1ld=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit next dev her açılışta AGENTS.md ve CLAUDE.md yazıyordu. Projenin tek doğruluk kaynağı kökteki CLAUDE.md; alt klasörde ikinci bir tane o tekliği bozuyor. --- dashboard/next.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dashboard/next.config.ts b/dashboard/next.config.ts index 1db9bba..95f2aaa 100644 --- a/dashboard/next.config.ts +++ b/dashboard/next.config.ts @@ -5,6 +5,12 @@ const nextConfig: NextConfig = { // değil yalnızca gerçekten kullanılan dosyaları imaja koyar (küçük imaj, // hızlı soğuk açılış — §9.12'deki uyku notu bu yüzden önemli). output: "standalone", + + // Next 16, `next dev` her açılışta AGENTS.md + CLAUDE.md üretiyor. Kapatıldı: + // bu projenin tek doğruluk kaynağı kökteki CLAUDE.md (md/ARCHITECTURE.md + // symlink'i). Alt klasörde ikinci bir CLAUDE.md, harness tarafından da + // otomatik yüklendiği için o tekliği sessizce bozardı. + agentRules: false, }; export default nextConfig; From 7545b5009379147181444deb980f8d50b81715d5 Mon Sep 17 00:00:00 2001 From: denis Date: Tue, 1 Sep 2026 13:44:44 +0300 Subject: [PATCH 07/13] =?UTF-8?q?fix(agent):=20a=C4=9F=20=C3=B6l=C3=A7?= =?UTF-8?q?=C3=BCm=C3=BC=20ve=20log=20g=C3=BCr=C3=BClt=C3=BCs=C3=BC=20d?= =?UTF-8?q?=C3=BCzeltildi,=20=C3=BCr=C3=BCn=20dili=20=C4=B0ngilizce=20oldu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - net_sent/net_recv artık loopback arayüzünü saymıyor, rakamlar şişkin değil - agent kendi rutin info satırlarını buluta göndermiyor (günde ~26k satır) - kullanıcıya görünen tüm metinler İngilizce: agent çıktısı, install.sh, uninstall.sh, config.example.toml, systemd birim açıklamaları --- agent/__main__.py | 20 ++-- agent/config.example.toml | 91 +++++++++--------- agent/core/commands.py | 32 +++---- agent/core/config.py | 33 ++++--- agent/core/loop.py | 56 ++++++------ agent/core/metrics.py | 56 +++++++++++- agent/core/shipper.py | 4 +- agent/core/state.py | 10 +- agent/core/verify.py | 16 ++-- agent/install.sh | 142 ++++++++++++++--------------- agent/logsources/linux_journald.py | 69 ++++++++++++-- agent/tracebox-agent.service | 8 +- agent/tracebox-uninstall.path | 2 +- agent/tracebox-uninstall.service | 2 +- agent/uninstall.sh | 44 ++++----- tests/test_config_defaults.py | 136 +++++++++++++++++++++++++++ tests/test_entrypoint.py | 2 +- tests/test_flush.py | 18 +++- tests/test_loop_commands.py | 2 +- tests/test_shipper.py | 2 +- tests/test_verify.py | 2 +- 21 files changed, 499 insertions(+), 248 deletions(-) create mode 100644 tests/test_config_defaults.py 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/tests/test_config_defaults.py b/tests/test_config_defaults.py new file mode 100644 index 0000000..8b97318 --- /dev/null +++ b/tests/test_config_defaults.py @@ -0,0 +1,136 @@ +""" +Varsayılan ayarların DÖRT kopyası birbirini tutuyor mu? + +Her ayar bugün dört ayrı yerde yazılı: + + 1. `Config` dataclass'ının alan varsayılanı — agent/core/config.py + 2. `_parse()`in `_positive_int(..., N)` yedeği — agent/core/config.py + 3. `config.example.toml` — kullanıcının okuduğu belge + 4. `install.sh`in yazdığı config.toml — kullanıcının GERÇEKTEN aldığı dosya + +Neden dört: (1) kod yolu için, (2) config'te alan hiç yoksa diye, (3) belgelemek +için, (4) kurulumda dosyayı üretmek için. Hiçbiri diğerini okuyamaz — biri TOML, +biri bash heredoc'u, ikisi Python. + +Bu testin sebebi somut: 2026-08-31'de `flush_cooldown_seconds` 20'den 10'a +indirildi ve DÖRDÜNÜN de elle düzeltilmesi gerekti. Biri unutulsaydı hiçbir şey +kırılmazdı — kurulan makine belgede yazandan farklı davranır, fark ancak aylar +sonra "neden bu makine öbürü gibi davranmıyor" diye bakarken görülürdü. + +Testler değerlerin NE olduğunu iddia etmiyor; yalnızca dördünün AYNI olduğunu +iddia ediyor. Değeri değiştirmek serbest, tek bir yerde değiştirmek değil. +""" + +from __future__ import annotations + +import re +import tomllib +from dataclasses import fields +from pathlib import Path + +import pytest + +from agent.core import config as config_module +from agent.core.config import MIN_SEND_INTERVAL_SECONDS, Config + +AGENT_DIR = Path(__file__).resolve().parent.parent / "agent" +EXAMPLE = AGENT_DIR / "config.example.toml" +INSTALL = AGENT_DIR / "install.sh" + +# Bağlantı alanlarının varsayılanı YOK ve olmamalı: ikisi de makineye özgü. +# Karşılaştırma dışında tutuluyorlar, yoksa örnek dosyadaki yer tutucu anahtar +# ile install.sh'in `${DEVICE_KEY}` değişkeni karşılaştırılmaya çalışılırdı. +CONNECTION_KEYS = {"collector_url", "device_key"} + +DEFAULTS: dict[str, object] = { + field.name: field.default + for field in fields(Config) + if field.name not in CONNECTION_KEYS +} + + +def example_toml() -> dict: + return tomllib.loads(EXAMPLE.read_text(encoding="utf-8")) + + +def install_toml() -> dict: + """install.sh'in ürettiği config.toml'u ayrıştırır. + + Betik çalıştırılmıyor: heredoc gövdesi metinden çıkarılıp kabuk değişkenleri + yer tutucularla değiştiriliyor. Amaç betiği test etmek değil, YAZDIĞI + dosyanın içeriğini görmek. + """ + body = INSTALL.read_text(encoding="utf-8") + match = re.search(r"<= MIN_SEND_INTERVAL_SECONDS + for document in (example_toml, install_toml): + assert document()["send_interval_seconds"] >= MIN_SEND_INTERVAL_SECONDS diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 09908d7..34443cd 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -46,7 +46,7 @@ def test_a_deleted_device_does_not_start_the_loop(tmp_path, monkeypatch, capsys) exit_code = entrypoint.main([]) assert started == [], "silinmiş cihazda döngü başlatıldı" - assert "silindi" in capsys.readouterr().out + assert "deleted" in capsys.readouterr().out def test_a_deleted_device_exits_without_asking_for_a_restart(tmp_path, monkeypatch, capsys): diff --git a/tests/test_flush.py b/tests/test_flush.py index 4dd6468..b772a94 100644 --- a/tests/test_flush.py +++ b/tests/test_flush.py @@ -222,10 +222,18 @@ def test_thresholds_come_from_the_config_not_from_constants(): # --- cooldown: sel koruması ------------------------------------------------ +# Testlerin kullandığı cooldown. `Config`in varsayılanından OKUNUYOR, elle +# yazılmıyor: değer 2026-08-31'de 20'den 10'a indirildiğinde bu bloktaki dört +# test sabit `20` taşıdığı için hiçbiri kırılmadı — yani üretimdeki değişikliği +# haber veren bir şey yoktu. Sınırlar da artık damga değil FARK olarak yazılı +# (COOLDOWN ± 1); önceki hâlde "25 saniye önce" 20'lik bir cooldown için gevşek +# bir sınırdı ve varsayılan düştükçe daha da gevşiyordu. +COOLDOWN = Config.flush_cooldown_seconds + def test_first_flush_is_never_blocked(): """Damga yoksa daha önce hiç flush edilmemiştir; cooldown kapalıdır.""" - assert flush.cooldown_active(None, 20) is False + assert flush.cooldown_active(None, COOLDOWN) is False def test_unreadable_stamp_does_not_block_the_flush(): @@ -235,15 +243,15 @@ def test_unreadable_stamp_does_not_block_the_flush(): aksi yönde yorumlanırsa tek bir bozuk satır acil gönderimi kalıcı olarak kapatırdı. """ - assert flush.cooldown_active("dün", 20) is False + assert flush.cooldown_active("dün", COOLDOWN) is False def test_recent_flush_blocks_the_next_one(): - assert flush.cooldown_active(iso_seconds_ago(5), 20) is True + assert flush.cooldown_active(iso_seconds_ago(COOLDOWN - 1), COOLDOWN) is True def test_cooldown_expires(): - assert flush.cooldown_active(iso_seconds_ago(25), 20) is False + assert flush.cooldown_active(iso_seconds_ago(COOLDOWN + 1), COOLDOWN) is False def test_a_stamp_from_the_future_does_not_lock_the_flush_forever(): @@ -253,7 +261,7 @@ def test_a_stamp_from_the_future_does_not_lock_the_flush_forever(): kadar — saatlerce, günlerce — açık kalır ve acil gönderim tamamen durur. O yüzden negatif fark, cooldown'ın kapalı olması demektir. """ - assert flush.cooldown_active(iso_seconds_ago(-3600), 20) is False + assert flush.cooldown_active(iso_seconds_ago(-3600), COOLDOWN) is False # --- build_crash_snapshot: wire satırı ------------------------------------- diff --git a/tests/test_loop_commands.py b/tests/test_loop_commands.py index 6e5af68..ded9c5b 100644 --- a/tests/test_loop_commands.py +++ b/tests/test_loop_commands.py @@ -164,7 +164,7 @@ def test_a_failed_poll_does_not_stop_the_agent(store, spool, capsys): stopped = poll(FakePoller(CommandError("bağlanılamadı")), state, store, spool, FakeShipper()) assert stopped is False - assert "komutlar alınamadı" in capsys.readouterr().out + assert "could not fetch commands" in capsys.readouterr().out def test_an_empty_queue_costs_nothing(store, spool): diff --git a/tests/test_shipper.py b/tests/test_shipper.py index 4f3c1a8..e73a68f 100644 --- a/tests/test_shipper.py +++ b/tests/test_shipper.py @@ -121,7 +121,7 @@ def test_connection_failure_leaves_every_record_in_the_spool(spool): assert result.ok is False assert spool.count() == 3 - assert "bağlanılamadı" in result.detail + assert "could not connect" in result.detail def test_rejected_key_leaves_every_record_in_the_spool(spool): diff --git a/tests/test_verify.py b/tests/test_verify.py index 2899386..f678ea7 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -142,7 +142,7 @@ def test_unexpected_body_does_not_fail_the_check(transport, response): def test_401_is_a_failure_about_the_key(transport): """Reddedilen anahtar, kullanıcıyı doğrudan device_key'e yönlendirmeli.""" - transport(respond(401, json={"detail": "Geçersiz cihaz anahtarı."})) + transport(respond(401, json={"detail": "Invalid device key."})) result = verify(make_config()) assert result.ok is False assert "device_key" in result.detail From f59b2ceb0a9eb79984c807eda7e841b38f342995 Mon Sep 17 00:00:00 2001 From: denis Date: Tue, 1 Sep 2026 14:36:21 +0300 Subject: [PATCH 08/13] =?UTF-8?q?feat(collector):=20CORS=20eklendi,=20dash?= =?UTF-8?q?board=20taray=C4=B1c=C4=B1dan=20cihaz=20a=C3=A7abiliyor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tarayıcıdan çağrılan tek uç POST /devices; agent uçlarını CORS hiç ilgilendirmiyor. Yıldız yerine açık liste kullanıldı: ileride çerez tabanlı bir kimliğe geçilirse yıldız sessizce gerçek bir açığa dönüşürdü. - izinli adresler TRACEBOX_ALLOWED_ORIGINS ile gelir, tanımsızsa yalnızca localhost:3000 - middleware yollardan önce takılıyor; olmasaydı tarayıcının ön sorusu 405 dönerdi - allow_credentials kapalı: kimlik çerezle değil Authorization başlığıyla taşınıyor --- collector/cors.py | 95 +++++++++++++++++++++++ collector/main.py | 7 ++ tests/test_cors.py | 185 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 collector/cors.py create mode 100644 tests/test_cors.py 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/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/tests/test_cors.py b/tests/test_cors.py new file mode 100644 index 0000000..1cc2957 --- /dev/null +++ b/tests/test_cors.py @@ -0,0 +1,185 @@ +""" +collector/cors.py — tarayıcıya verilen izin listesi. + +Bu ayarın bozulması SESSİZDİR: sunucu ayakta, uç çalışıyor, testler geçiyor ama +dashboard'un "Add Host" düğmesi hiçbir istek göndermeden başarısız oluyor. Hata +yalnızca tarayıcının konsolunda görünür — sunucu loglarında hiçbir iz yok. O +yüzden preflight yanıtı burada doğrudan sınanıyor. +""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cors import ( + ALLOWED_ORIGINS_ENV, + DEFAULT_ORIGINS, + install_cors, + parse_origins, +) + +ALLOWED = "https://dash.example.com" +FOREIGN = "https://evil.example.com" + + +@pytest.fixture +def client(monkeypatch): + """CORS'u tek bir origin'e açılmış, boş bir uygulama. + + `main.app` KULLANILMIYOR: o uygulama import anında bir kez kuruluyor, yani + env değişkeni testte değiştirilse bile geç kalıyor olurdu. + """ + monkeypatch.setenv(ALLOWED_ORIGINS_ENV, ALLOWED) + + app = FastAPI() + install_cors(app) + + @app.post("/devices") + async def _devices() -> dict: + return {"ok": True} + + return TestClient(app) + + +# --- parse_origins ------------------------------------------------------- + + +def test_parse_origins_bos_deger_bos_liste(): + assert parse_origins(None) == [] + assert parse_origins("") == [] + assert parse_origins(" , ") == [] + + +def test_parse_origins_bosluk_kirpar(): + assert parse_origins(" https://a.com , https://b.com ") == [ + "https://a.com", + "https://b.com", + ] + + +def test_parse_origins_sondaki_egik_cizgiyi_atar(): + """Tarayıcının `Origin` başlığı eğik çizgi taşımaz; ayar taşırsa eşleşmez.""" + assert parse_origins("https://a.com/") == ["https://a.com"] + + +# --- preflight (OPTIONS) ------------------------------------------------- + + +def test_preflight_izinli_origin_kabul_edilir(client): + response = client.options( + "/devices", + headers={ + "Origin": ALLOWED, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization,content-type", + }, + ) + + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == ALLOWED + + +def test_preflight_yabanci_origin_reddedilir(client): + """Reddedilen istek 200 dönebilir; belirleyici olan BAŞLIĞIN YOKLUĞU. + + Tarayıcı `Access-Control-Allow-Origin` görmediği sürece yanıtı sayfaya + teslim etmez. Durum koduna bakan bir test bu yüzden yanıltıcı olurdu. + """ + response = client.options( + "/devices", + headers={"Origin": FOREIGN, "Access-Control-Request-Method": "POST"}, + ) + + assert response.headers.get("access-control-allow-origin") != FOREIGN + + +def test_gercek_istekte_izin_basligi_dondurulur(client): + response = client.post("/devices", headers={"Origin": ALLOWED}, json={}) + + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == ALLOWED + + +def test_yabanci_origin_gercek_istekte_de_isaretlenmez(client): + response = client.post("/devices", headers={"Origin": FOREIGN}, json={}) + + assert response.headers.get("access-control-allow-origin") is None + + +def test_kimlik_bilgisi_paylasimi_kapali(client): + """Kimlik `Authorization` başlığıyla taşınıyor, çerezle değil.""" + response = client.post("/devices", headers={"Origin": ALLOWED}, json={}) + + assert "access-control-allow-credentials" not in response.headers + + +def test_yalnizca_post_acik(client): + """DELETE gibi bir yöntem preflight'tan onay ALMAMALI. + + Starlette bu durumda 400 dönüyor ve gerekçeyi gövdeye yazıyor. Origin + başlığı yanıtta yine bulunuyor (origin'in kendisi izinli); reddi taşıyan + şey durum kodu, o yüzden test ona bakıyor. + """ + response = client.options( + "/devices", + headers={"Origin": ALLOWED, "Access-Control-Request-Method": "DELETE"}, + ) + + assert response.status_code == 400 + assert "method" in response.text + + +def test_izinli_yontemler_yalnizca_post_ilan_eder(client): + """Preflight yanıtının ilan ettiği liste POST dışına taşmamalı.""" + response = client.options( + "/devices", + headers={"Origin": ALLOWED, "Access-Control-Request-Method": "POST"}, + ) + + advertised = { + method.strip() + for method in response.headers["access-control-allow-methods"].split(",") + } + assert advertised == {"POST"} + + +# --- varsayılan liste ---------------------------------------------------- + + +def test_env_yoksa_varsayilan_liste_kullanilir(monkeypatch): + monkeypatch.delenv(ALLOWED_ORIGINS_ENV, raising=False) + + app = FastAPI() + install_cors(app) + + @app.post("/devices") + async def _devices() -> dict: + return {"ok": True} + + local = TestClient(app) + response = local.post( + "/devices", headers={"Origin": DEFAULT_ORIGINS[0]}, json={} + ) + + assert response.headers["access-control-allow-origin"] == DEFAULT_ORIGINS[0] + + +def test_env_varsa_varsayilan_liste_DUSER(monkeypatch): + """Env listeyi genişletmez, DEĞİŞTİRİR — üretimde localhost açık kalmasın.""" + monkeypatch.setenv(ALLOWED_ORIGINS_ENV, ALLOWED) + + app = FastAPI() + install_cors(app) + + @app.post("/devices") + async def _devices() -> dict: + return {"ok": True} + + local = TestClient(app) + response = local.post( + "/devices", headers={"Origin": DEFAULT_ORIGINS[0]}, json={} + ) + + assert response.headers.get("access-control-allow-origin") is None From f46802faa2cf30f9902ccedafb349d8f33138a72 Mon Sep 17 00:00:00 2001 From: denis Date: Tue, 1 Sep 2026 14:36:28 +0300 Subject: [PATCH 09/13] =?UTF-8?q?feat(m9):=20dashboard=20ekranlar=C4=B1,?= =?UTF-8?q?=20iki=20tema=20ve=20a=C4=9F=20seyreltmesi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dokuz sayfa: overview, hosts, host detayı, metrics, logs, alerts, inventory, settings, login. Veriyi tarayıcı doğrudan Supabase üzerinden RLS altında çekiyor; collector tarafına giden tek istek POST /devices. - overview cihaz ölçümlerini değil boru hattını anlatıyor: veri yolu, gecikme, acil gönderimler, saklama - açık ve koyu tema aynı CSS değişkenleri üzerinden yürüyor, dark öneki yok - migration 0005: metrics_buckets fonksiyonuna ağ sütunları eklendi, db/schema.sql aynı fonksiyonla eşitlendi - Dockerfile, dockerignore ve fly.toml eklendi; deploy hazır --- dashboard/.dockerignore | 37 ++ dashboard/Dockerfile | 96 +++ dashboard/app/(app)/alerts/page.tsx | 223 +++++++ dashboard/app/(app)/devices/AddHostDialog.tsx | 297 +++++++++ dashboard/app/(app)/devices/DeviceCard.tsx | 52 +- dashboard/app/(app)/devices/SummaryCards.tsx | 156 +++++ dashboard/app/(app)/devices/[id]/Chart.tsx | 244 -------- .../app/(app)/devices/[id]/DetailPanel.tsx | 260 ++++++-- dashboard/app/(app)/devices/[id]/LogList.tsx | 243 -------- dashboard/app/(app)/devices/[id]/Timeline.tsx | 197 ------ dashboard/app/(app)/devices/[id]/page.tsx | 37 +- dashboard/app/(app)/devices/page.tsx | 130 ++-- dashboard/app/(app)/inventory/page.tsx | 357 +++++++++++ dashboard/app/(app)/layout.tsx | 17 +- dashboard/app/(app)/logs/page.tsx | 79 +++ dashboard/app/(app)/metrics/page.tsx | 122 ++++ dashboard/app/(app)/overview/DataPath.tsx | 162 +++++ dashboard/app/(app)/overview/FleetStrip.tsx | 64 ++ dashboard/app/(app)/overview/FlushEvents.tsx | 123 ++++ dashboard/app/(app)/overview/HealthStrip.tsx | 172 +++++ dashboard/app/(app)/overview/IngestLag.tsx | 98 +++ dashboard/app/(app)/overview/Retention.tsx | 92 +++ dashboard/app/(app)/overview/page.tsx | 191 ++++++ dashboard/app/(app)/settings/page.tsx | 276 ++++++++ dashboard/app/globals.css | 212 +++++-- dashboard/app/layout.tsx | 18 +- dashboard/app/login/AuthPanel.tsx | 327 ++++++++++ dashboard/app/login/FlightRecorderScene.tsx | 371 +++++++++++ dashboard/app/login/page.tsx | 211 ++++--- dashboard/app/page.tsx | 4 +- dashboard/components/ConfirmDialog.tsx | 182 ++++++ dashboard/components/CrashMarkers.tsx | 200 ++++++ dashboard/components/LogList.tsx | 587 ++++++++++++++++++ dashboard/components/MetricChart.tsx | 347 +++++++++++ dashboard/components/PageHeader.tsx | 66 ++ dashboard/components/Panel.tsx | 70 +++ dashboard/components/SelectBox.tsx | 51 ++ dashboard/components/Sidebar.tsx | 270 ++++---- dashboard/components/ThemeToggle.tsx | 31 + dashboard/components/Timeline.tsx | 451 ++++++++++++++ dashboard/components/Topbar.tsx | 261 ++++---- dashboard/components/icons.tsx | 163 +++++ dashboard/fly.toml | 52 ++ dashboard/lib/account.ts | 36 ++ dashboard/lib/alerts.ts | 48 +- dashboard/lib/appState.tsx | 182 +++++- dashboard/lib/collector.ts | 91 +++ dashboard/lib/commands.ts | 36 ++ dashboard/lib/crashes.ts | 110 ++++ dashboard/lib/devices.ts | 138 +++- dashboard/lib/errors.ts | 39 ++ dashboard/lib/health.ts | 511 +++++++++++++++ dashboard/lib/logs.ts | 126 ++-- dashboard/lib/metrics.ts | 373 ++++++++++- dashboard/lib/realtime.ts | 82 +++ dashboard/lib/supabase.ts | 2 +- dashboard/lib/theme.tsx | 82 +++ dashboard/lib/time.ts | 118 +++- db/migrations/0005_metrics_buckets_net.sql | 165 +++++ db/schema.sql | 21 +- 60 files changed, 8438 insertions(+), 1321 deletions(-) create mode 100644 dashboard/.dockerignore create mode 100644 dashboard/Dockerfile create mode 100644 dashboard/app/(app)/alerts/page.tsx create mode 100644 dashboard/app/(app)/devices/AddHostDialog.tsx create mode 100644 dashboard/app/(app)/devices/SummaryCards.tsx delete mode 100644 dashboard/app/(app)/devices/[id]/Chart.tsx delete mode 100644 dashboard/app/(app)/devices/[id]/LogList.tsx delete mode 100644 dashboard/app/(app)/devices/[id]/Timeline.tsx create mode 100644 dashboard/app/(app)/inventory/page.tsx create mode 100644 dashboard/app/(app)/logs/page.tsx create mode 100644 dashboard/app/(app)/metrics/page.tsx create mode 100644 dashboard/app/(app)/overview/DataPath.tsx create mode 100644 dashboard/app/(app)/overview/FleetStrip.tsx create mode 100644 dashboard/app/(app)/overview/FlushEvents.tsx create mode 100644 dashboard/app/(app)/overview/HealthStrip.tsx create mode 100644 dashboard/app/(app)/overview/IngestLag.tsx create mode 100644 dashboard/app/(app)/overview/Retention.tsx create mode 100644 dashboard/app/(app)/overview/page.tsx create mode 100644 dashboard/app/(app)/settings/page.tsx create mode 100644 dashboard/app/login/AuthPanel.tsx create mode 100644 dashboard/app/login/FlightRecorderScene.tsx create mode 100644 dashboard/components/ConfirmDialog.tsx create mode 100644 dashboard/components/CrashMarkers.tsx create mode 100644 dashboard/components/LogList.tsx create mode 100644 dashboard/components/MetricChart.tsx create mode 100644 dashboard/components/PageHeader.tsx create mode 100644 dashboard/components/Panel.tsx create mode 100644 dashboard/components/SelectBox.tsx create mode 100644 dashboard/components/ThemeToggle.tsx create mode 100644 dashboard/components/Timeline.tsx create mode 100644 dashboard/fly.toml create mode 100644 dashboard/lib/account.ts create mode 100644 dashboard/lib/collector.ts create mode 100644 dashboard/lib/commands.ts create mode 100644 dashboard/lib/crashes.ts create mode 100644 dashboard/lib/errors.ts create mode 100644 dashboard/lib/health.ts create mode 100644 dashboard/lib/realtime.ts create mode 100644 dashboard/lib/theme.tsx create mode 100644 db/migrations/0005_metrics_buckets_net.sql 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/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..4da1b51 --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,96 @@ +# ============================================================================= +# 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 "HATA: $v bos. fly.toml icindeki [build.args] bolumunu doldurun."; \ + 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/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)}`} + + + + +
  • + ); +} + +export default function AlertsPage() { + const { devices, error, now, hostFilter } = useApp(); + + const scoped = useMemo(() => { + if (!devices) return []; + return hostFilter ? devices.filter((d) => d.id === hostFilter) : devices; + }, [devices, hostFilter]); + + const alerts = useMemo(() => buildAlerts(scoped, now), [scoped, now]); + + const grouped = useMemo(() => { + const map = new Map(); + for (const severity of ORDER) map.set(severity, []); + for (const alert of alerts) map.get(alert.severity)?.push(alert); + return map; + }, [alerts]); + + const scopeName = + hostFilter && devices + ? (devices.find((d) => d.id === hostFilter)?.device_name ?? null) + : null; + + const count = (s: AlertSeverity) => grouped.get(s)?.length ?? 0; + + return ( +
    + + {devices && ( +
    + + + +
    + )} +
    + + {error && ( +

    + Could not read hosts: {error} +

    + )} + + {devices && alerts.length === 0 && ( +

    + {devices.length === 0 ? ( + <> + No hosts yet. Add one from{" "} + + Hosts + + . + + ) : ( + "Nothing to report — every host is reachable and under its thresholds." + )} +

    + )} + +
    + {ORDER.filter((severity) => count(severity) > 0).map((severity) => ( +
    +
    +

    + {SEVERITY_LABEL[severity]} +

    + + {count(severity)} + +
    +
      + {grouped.get(severity)?.map((alert) => ( + + ))} +
    +
    + ))} +
    + + {/* --- 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. +

    + )} +
    + ); +} + +export function AddHostDialog({ + onClose, + onCreated, +}: { + onClose: () => void; + /** Liste yeni cihazı hemen göstersin diye kabuğun `reload`'u tetiklenir. */ + onCreated: () => void; +}) { + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [created, setCreated] = useState<{ name: string; key: string } | null>( + null, + ); + const [acknowledged, setAcknowledged] = useState(false); + const nameRef = useRef(null); + + // Anahtar ekrandayken pencere kilitli (§9.10). + const locked = created !== null; + + useEffect(() => { + nameRef.current?.focus(); + }, []); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !locked && !busy) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [locked, busy, onClose]); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + const trimmed = name.trim(); + if (!trimmed || busy) return; + + setBusy(true); + setError(null); + try { + const device = await createDevice(trimmed); + // Cihaz ADI yanıttan alınıyor, formdan değil: collector baştaki ve + // sondaki boşlukları kırpıyor, yani kayıtlı ad farklı olabilir. + setCreated({ name: device.device_name, key: device.device_key }); + onCreated(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(false); + } + } + + return ( +
    { + if (!locked && !busy && event.target === event.currentTarget) onClose(); + }} + > +
    + {/* --- başlık ---------------------------------------------------- */} +
    + + + +
    +

    + {locked ? "Save this key now" : "Add host"} +

    +

    + {locked + ? "This key is shown once and cannot be recovered." + : "Give the machine a name. The agent key is generated on the collector."} +

    +
    + + {/* Kilitliyken kapatma çarpısı YOK — tek çıkış onay kutusu. */} + {!locked && ( + + )} +
    + + {/* --- 1. aşama: isim -------------------------------------------- */} + {!locked && ( +
    + +

    + Must be unique within your account. +

    + + {error && ( +

    + {error} +

    + )} + +
    + + +
    +
    + )} + + {/* --- 2. aşama: anahtar ----------------------------------------- */} + {created && ( +
    +

    + 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 index f66c60e..404bae3 100644 --- a/dashboard/app/(app)/devices/DeviceCard.tsx +++ b/dashboard/app/(app)/devices/DeviceCard.tsx @@ -14,30 +14,24 @@ import Link from "next/link"; import { deviceStatus, type Device } from "@/lib/devices"; -import { FLUSH_THRESHOLD, SERIES, type SeriesDef } from "@/lib/metrics"; +import { SERIES, type SeriesDef } from "@/lib/metrics"; import { relativeTime } from "@/lib/time"; -import { NEAR_THRESHOLD_MARGIN } from "@/lib/alerts"; +import { percentTone } from "@/lib/alerts"; import { StatusPill } from "@/components/StatusPill"; -import { IconChevron, IconClock, IconServer } from "@/components/icons"; +import { + IconAlert, + IconChevron, + IconClock, + IconServer, +} from "@/components/icons"; -/** - * Çubuğun rengi normalde ÖLÇÜNÜN rengi (CPU indigo, RAM mor, Disk cyan) — - * plan §2'nin "renk metric ayrımı yapar" maddesi. 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. - * - * Eşikler agent'ın ACİL GÖNDERİM eşikleri (agent/config.example.toml: cpu 90, - * ram 90, disk 95). Kırmızı çubuk keyfi bir tasarım kararı değil, tam olarak - * şunu söylüyor: "agent bu değeri acil sayıp spool'u beklemeden flush ederdi". +/* + * Ç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 tone(series: SeriesDef, percent: number | null) { - if (percent == null) return { bar: "bg-line", text: "text-faint" }; - const limit = FLUSH_THRESHOLD[series.key]; - if (percent >= limit) return { bar: "bg-danger", text: "text-danger" }; - if (percent >= limit - NEAR_THRESHOLD_MARGIN) - return { bar: "bg-warn", text: "text-warn" }; - return { bar: series.tone.bar, text: "text-fg" }; -} function Metric({ series, @@ -49,13 +43,21 @@ function Metric({ value: string; }) { const clamped = percent == null ? null : Math.min(100, Math.max(0, percent)); - const { bar, text } = tone(series, clamped); + const { bar, text, alarm } = percentTone(series, clamped); return (
    {series.label} - {value} + + {/* + 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} +
    {clamped != null && ( @@ -76,7 +78,7 @@ export function DeviceCard({ device, now }: { device: Device; now: number }) { const subtitle = [ [device.os_name, device.os_version].filter(Boolean).join(" "), - device.cpu_cores_logical ? `${device.cpu_cores_logical} çekirdek` : null, + device.cpu_cores_logical ? `${device.cpu_cores_logical} cores` : null, device.arch, ] .filter(Boolean) @@ -85,7 +87,7 @@ export function DeviceCard({ device, now }: { device: Device; now: number }) { return (
    @@ -115,7 +117,7 @@ export function DeviceCard({ device, now }: { device: Device; now: number }) {
    - {relativeTime(device.last_seen, now)} görüldü + 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]/Chart.tsx b/dashboard/app/(app)/devices/[id]/Chart.tsx deleted file mode 100644 index 59d901f..0000000 --- a/dashboard/app/(app)/devices/[id]/Chart.tsx +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Tek ölçünün grafiği: bant + çizgi (CLAUDE.md §9.6). - * - * BANT min–max, İÇİNDEN GEÇEN ÇİZGİ ortalama. İkisi birlikte çizilir çünkü - * ikisi ayrı soruya cevap verir: çizgi "makine genelde ne durumdaydı", bant - * "en kötü an neydi". Yalnızca ortalama çizilseydi 15 dakikalık bir kovanın - * içindeki 5 saniyelik patlama görünmezdi — ve o patlamayı göstermek bu - * projenin varlık sebebi. Yalnızca max çizilseydi makine sürekli yanıyormuş - * gibi görünürdü. - * - * Çizim el yazımı SVG; grafik kütüphanesi eklenmedi. Gereken şey iki path, - * birkaç ızgara çizgisi ve bir imleç — bunun için ~100 KB bağımlılık taşımak - * "over-engineering yok" ilkesine ters düşerdi. Ayrıca §9.8 fare tekerleğiyle - * yakınlaştırmayı YASAKLIYOR; hazır kütüphanelerin çoğunda o davranış - * varsayılan ve kapatmak, kendi çizmekten daha çok uğraş çıkarıyor. - * - * viewBox sabit (1000×100) ve preserveAspectRatio="none": koordinat matematiği - * yüzdelere dönüşmeden basit kalıyor, SVG kabına göre esniyor. Esneme çizgi - * kalınlığını da bozardı, onu vector-effect="non-scaling-stroke" tutuyor. - * Metin bu yüzden SVG İÇİNDE yok — yatay/dikey esneme yazıyı ezerdi; eksen - * etiketleri HTML olarak dışarıda duruyor. - */ -"use client"; - -import { useMemo } from "react"; -import { - ceilingFor, - formatValue, - splitOnGaps, - thresholdFor, - toPoints, - type MetricBucket, - type Point, - type SeriesDef, -} from "@/lib/metrics"; - -/** viewBox birimleri. Piksel değil — SVG kabına göre esner. */ -const W = 1000; -const H = 100; - -export function Chart({ - series, - buckets, - ramTotalMb, - fromMs, - toMs, - widthMs, - hoverIndex, - onHoverTime, -}: { - series: SeriesDef; - buckets: MetricBucket[]; - /** RAM grafiğinin tavanı. Yüzde grafikleri bunu kullanmaz. */ - ramTotalMb: number | null; - fromMs: number; - toMs: number; - /** Bir kovanın süresi — boşluk tespiti bunu ölçüt alır. */ - widthMs: number; - hoverIndex: number | null; - onHoverTime: (t: number | null) => void; -}) { - const { segments, ceiling, threshold } = useMemo(() => { - const points = toPoints(buckets, series); - const ceil = ceilingFor(series, ramTotalMb, points); - return { - segments: splitOnGaps(points, widthMs), - ceiling: ceil, - threshold: thresholdFor(series, ceil), - }; - }, [buckets, series, ramTotalMb, widthMs]); - - const span = Math.max(1, toMs - fromMs); - const x = (t: number) => ((t - fromMs) / span) * W; - const y = (v: number) => H - (Math.min(Math.max(v, 0), ceiling) / ceiling) * H; - - const hovered = hoverIndex == null ? null : buckets[hoverIndex]; - const hoveredBand = hovered ? series.pick(hovered) : null; - - function handleMove(event: React.MouseEvent) { - const box = event.currentTarget.getBoundingClientRect(); - const fraction = (event.clientX - box.left) / box.width; - onHoverTime(fromMs + fraction * span); - } - - return ( -
    -
    - {/* Lejant (plan §6). Nokta, aşağıdaki çizgiyle AYNI renk sınıfından - besleniyor; iki yerde ayrı ayrı seçilseydi biri değiştiğinde - lejant sessizce yanlış ölçüyü göstermeye başlardı. */} -
    - -
    onHoverTime(null)} - > - - {/* Izgara: dörtte birler. Değer okumak için değil, göz hizası için. */} - {[0.25, 0.5, 0.75].map((f) => ( - - ))} - - {/* Acil gönderim eşiği. Bu çizginin üstü keyfi bir "tehlike" değil: - agent orada spool'u beklemeden anında boşaltır (§7). */} - {threshold < ceiling && ( - - )} - - {segments.map((segment, i) => - segment.length === 1 ? ( - // Tek noktalık parça: bant kapalı bir alan oluşturamaz, dolgu - // görünmez. Min–max yine de kaybolmasın diye dikey çizgi. - - ) : ( - - ), - )} - - {segments.map((segment, i) => ( - - ))} - - {hovered && ( - - )} - - - - {/* - İmlecin altındaki ortalama değeri işaretleyen nokta — SVG'nin İÇİNDE - DEĞİL, üstünde duran bir HTML öğesi. - - Sebep: viewBox 1000x100 ve preserveAspectRatio="none", yani SVG kabına - göre yatayda ve dikeyde farklı oranlarda esniyor. İçeriye konan bir - daire o esnemeyle birlikte yayvan bir elipse dönerdi; - vector-effect yalnızca çizgi kalınlığını kurtarır, şekli değil. - Dışarıda yüzdeyle konumlanan bir nokta her boyutta yuvarlak kalıyor. - */} - {hovered && hoveredBand?.avg != null && ( - - )} -
    -
    - ); -} - -/** Üstten max boyunca ileri, alttan min boyunca geri: kapalı bant. */ -function bandPath( - points: Point[], - x: (t: number) => number, - y: (v: number) => number, -): string { - const top = points.map((p) => `${x(p.t)},${y(p.max)}`); - const bottom = [...points].reverse().map((p) => `${x(p.t)},${y(p.min)}`); - return `M${top.join("L")}L${bottom.join("L")}Z`; -} - -function linePath( - points: Point[], - x: (t: number) => number, - y: (v: number) => number, -): string { - return `M${points.map((p) => `${x(p.t)},${y(p.avg)}`).join("L")}`; -} diff --git a/dashboard/app/(app)/devices/[id]/DetailPanel.tsx b/dashboard/app/(app)/devices/[id]/DetailPanel.tsx index 5382222..6f80780 100644 --- a/dashboard/app/(app)/devices/[id]/DetailPanel.tsx +++ b/dashboard/app/(app)/devices/[id]/DetailPanel.tsx @@ -12,14 +12,30 @@ * 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, 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, @@ -28,6 +44,13 @@ import { 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 (
    @@ -59,15 +82,67 @@ function Group({ export function DetailPanel({ device, now, + onChanged, }: { device: DeviceDetail; now: number; + /** Komut yazıldıktan sonra künyeyi hemen tazeler — 10 saniyelik turu + beklemek, kullanıcıya butonun işe yaramadığını düşündürürdü. */ + onChanged: () => void; }) { + const { accountId, reload } = useApp(); + const router = useRouter(); const status = deviceStatus(device, now); + const [dialog, setDialog] = useState("none"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Bekleyen komut türleri kuyruktan okunuyor (§6: ara durumun ayrı bir bayrağı + // yok). Aynı emri ikinci kez sıraya koymayı engelleyen tek şey bu. + const pending = new Set(device.pendingCommands); + const togglePending = pending.has("pause") || pending.has("resume"); + const deletePending = pending.has("delete"); + + async function send(type: CommandType) { + setBusy(true); + setError(null); + try { + await queueCommand({ deviceId: device.id, accountId, type }); + setDialog("none"); + onChanged(); + reload(); // sidebar ve cihaz listesi de aynı anda tazelensin + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(false); + } + } + + /** + * Zorla kaldırma — komut kuyruğunu atlayıp satırı doğrudan siler (§6). + * + * Sonrasında listeye dönmek ZORUNLU: bu sayfanın konu aldığı satır artık + * yok, yerinde kalmak kullanıcıyı bir "bulunamadı" ekranına bakar durumda + * bırakırdı. `replace` kullanılıyor ki geri düğmesi silinmiş cihazın + * detayına dönmesin. + */ + async function forceRemove() { + setBusy(true); + setError(null); + try { + await forceRemoveDevice(device.id); + reload(); + router.replace("/devices"); + } catch (e) { + setError(errorMessage(e)); + setBusy(false); + } + } + return (
    - - + + - + {device.gpu_model && } - + - - + + {device.external_ip && ( - + )} - - + + {device.enabled_addons.length > 0 && ( - + )} - {/* - Aksiyonlar bilerek DEVRE DIŞI. pause/resume/delete, commands tablosuna - INSERT atmak kadar basit değil: silme geri alınamaz ve §9.10 her yıkıcı - işlem için ayrı bir onay penceresi şart koşuyor (cihazın adını yazdırma, - Enter/Esc'in güvenli tarafa düşmesi). O pencere yazılmadan butonu canlı - bırakmak, kuralı kâğıt üstünde bırakmak olurdu. - - "Sil" butonu vurgusuz duruyor (dolu kırmızı değil, kırmızı kenarlıklı): - §9.10 "tehlikeli buton vurgusuzdur" diyor — göz önce güvenli seçeneğe - gitmeli. - */} -
    +
    + + {/* + "Delete" vurgusuz (§9.10: tehlikeli buton dolu zemin almaz) ve pencere + açmaktan başka bir şey yapmıyor — asıl işlem onaydan sonra. + */} + + {/* + Komut anında uygulanmıyor: agent ~10 saniyede bir soruyor (§7). + Bunu yazmak zorunlu — yazmasak buton basılmış ama hiçbir şey olmamış + gibi görünürdü. Cihaz sessizse dahası da var: emir, makine geri + dönene kadar sırada bekler. + */} + {(togglePending || deletePending) && ( +

    + {status === "offline" + ? "This host is offline. The command stays queued until the agent reconnects." + : "The agent applies it on its next command poll (~10s)."} +

    + )} + + {/* + ZORLA KALDIRMA — yalnızca cihaz ÇEVRİMDIŞIYKEN görünüyor (§9.13'te + açık kalan "nerede duracak" sorusunun cevabı). + + Çevrimiçi bir cihazda gösterilmiyor, çünkü orada zararlı: satır + silinir, ama makinedeki agent kurulu kalır ve artık hiçbir satırla + eşleşmeyen anahtarıyla sonsuza kadar 401 alır. Ulaşılabilir bir + makinede doğru yol her zaman normal silme — agent kendini temizler. + Buton ancak o yol İŞLEMEZ hâle geldiğinde, yani makine sessizken + ortaya çıkıyor. + */} + {status === "offline" && ( + + )} + + {error && dialog === "none" && ( +

    + Could not send the command: {error} +

    + )}
    + + {dialog === "delete" && ( + send("delete")} + onCancel={() => setDialog("none")} + > + {/* + Ne olacağı tek tek yazılıyor. "Emin misiniz?" diye sormak, sorunun + neyi kapsadığını kullanıcının tahminine bırakmak olurdu: silinen + yalnızca satır değil, cihazın 10 günlük tüm geçmişi (CASCADE, §5) — + ve agent kendi kendini makineden kaldırıyor (§7). + */} +

    + All metrics, logs and crash snapshots for{" "} + {device.device_name} will be + deleted. +

    +

    + On its next command poll the agent uninstalls itself: the service is + stopped, and the config and device key are wiped. +

    + {status === "offline" && ( +

    + This host is offline. The command will wait in the queue — if the + machine never reconnects, the record stays here. +

    + )} +
    + )} + + {dialog === "force" && ( + setDialog("none")} + > +

    + This deletes {device.device_name}{" "} + and all of its data immediately, without waiting for the agent. +

    + {/* + Kullanıcının bilmeden bırakabileceği kalıntı burada tek tek + yazılıyor. Bunu söylememek, "silindi" diyip makinede çalışmaya + devam eden bir servis bırakmak olurdu. + */} +

    + The agent is not uninstalled. If + that machine ever comes back it will keep running with a key that no + longer matches anything, so run{" "} + + uninstall.sh + {" "} + on it if you can. +

    +

    + Use this only when the machine is gone for good. For a host you can + still reach, Delete is the right button — the agent cleans itself + up. +

    +
    + )} + ); } diff --git a/dashboard/app/(app)/devices/[id]/LogList.tsx b/dashboard/app/(app)/devices/[id]/LogList.tsx deleted file mode 100644 index 94851a9..0000000 --- a/dashboard/app/(app)/devices/[id]/LogList.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Log listesi (CLAUDE.md §9.4 + §9.5). - * - * Kaydırdıkça artımlı gelir: önce en yeni blok, o bittikçe bir öncesi. - * Kullanıcı dibe DEĞMEDEN önden çekilir (sentinel, viewport'un ~600px altında) - * — dibe değince yüklemek, her seferinde görünür bir duraklama demek olurdu. - * - * Blok mantığının tamamı lib/logs.ts'te; buradaki iş yalnızca "sırada hangi - * sayfa var" defterini tutmak. - */ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - LEVEL_FILTER_LABEL, - blocksForRange, - fetchLogPage, - type LevelFilter, - type LogLevel, - type LogRow, -} from "@/lib/logs"; -import { logTime } from "@/lib/time"; -import { IconList } from "@/components/icons"; - -/** - * Seviye rozetleri (tasarım planı §13: "log severity seviyelerini renklerle - * ayır"). Renk anlamla eşleşiyor — kehribar uyarı, kırmızı hata — ve aynı - * anlamlar dashboard'ın her yerinde geçerli (plan §15). - * - * critical, error'dan DOLU zeminle ayrılıyor. İkisi de kırmızı olmalı, çünkü - * ikisi de agent'ta acil flush tetikliyor (§7); ama aralarındaki fark bir - * kelimeyi okumaya bırakılamayacak kadar önemli. Dolu zemin, göz satırları - * tararken durduran tek işaret. - */ -const LEVEL_STYLE: Record = { - info: "border-line bg-panel-2 text-muted", - warning: "border-warn/25 bg-warn/10 text-warn", - error: "border-danger/25 bg-danger/10 text-danger", - critical: "border-danger bg-danger text-white", -}; - -const FILTERS: LevelFilter[] = ["all", "warning", "error"]; - -export function LogList({ - deviceId, - rangeSeconds, - anchor, - now, -}: { - deviceId: string; - /** Seçili aralığın uzunluğu (§9.5). */ - rangeSeconds: number; - /** - * Aralığın hesaplandığı an. Saniyede bir tikleyen "now" DEĞİL — o olsaydı - * aralığın alt sınırı her saniye kayar, listenin tamamı sürekli yeniden - * çekilirdi. Yalnızca kullanıcı aralığı değiştirdiğinde tazelenir. - */ - anchor: number; - now: number; -}) { - const [level, setLevel] = useState("all"); - const [rows, setRows] = useState([]); - const [loading, setLoading] = useState(false); - const [finished, setFinished] = useState(false); - const [error, setError] = useState(null); - - const blocks = useMemo( - () => blocksForRange(anchor, rangeSeconds), - [anchor, rangeSeconds], - ); - const rangeStartMs = anchor - rangeSeconds * 1000; - - // Defter: kaçıncı bloktayız, o blokta kaç satır geçtik. Render'ı - // ilgilendirmediği için state değil ref — her sayfada yeniden çizim olmaz. - const cursor = useRef({ block: 0, offset: 0 }); - const scroller = useRef(null); - const sentinel = useRef(null); - - // Eşzamanlılık kilidi state DEĞİL ref: state güncellemesi asenkron, iki - // sentinel tetiklemesi arka arkaya gelirse ikisi de "boşta" görür ve aynı - // sayfa iki kez inerdi. - const busy = useRef(false); - - /** - * Süzgeç, uçuştaki bir istekten daha hızlı değiştirilebilir. Epoch olmadan - * "hepsi" için başlamış bir sayfa, kullanıcı "error"a geçtikten sonra dönüp - * info satırlarını listeye ekleyebilirdi — ekran süzgecin yalan söylediğini - * gösterirdi. Yanıt, başladığı epoch hâlâ geçerliyse uygulanır. - */ - const epoch = useRef(0); - - // Cihaz / aralık / süzgeç değişti → defter başa sarılır. - useEffect(() => { - epoch.current += 1; - cursor.current = { block: 0, offset: 0 }; - busy.current = false; - setRows([]); - setFinished(false); - setError(null); - scroller.current?.scrollTo({ top: 0 }); - }, [deviceId, level, blocks]); - - const loadMore = useCallback(async () => { - if (busy.current) return; - busy.current = true; - setLoading(true); - - const mine = epoch.current; - try { - // Boş bloklar sessizce atlanır: 10 günün 7'sinde hiç error yoksa - // kullanıcının 7 kez daha kaydırması gerekmemeli. - while (cursor.current.block < blocks.length) { - const { block, offset } = cursor.current; - const page = await fetchLogPage({ - deviceId, - block: blocks[block], - level, - offset, - rangeStartMs, - }); - if (mine !== epoch.current) return; // sorgu eskidi, yanıtı at - - cursor.current = page.blockDone - ? { block: block + 1, offset: 0 } - : { block, offset: offset + page.rows.length }; - - if (page.rows.length > 0) { - setRows((prev) => [...prev, ...page.rows]); - break; - } - } - if (cursor.current.block >= blocks.length) setFinished(true); - setError(null); - } catch (e) { - if (mine !== epoch.current) return; - setError(e instanceof Error ? e.message : String(e)); - setFinished(true); // durmadan yeniden denemek hatayı çoğaltmaktan başka işe yaramaz - } finally { - if (mine === epoch.current) busy.current = false; - setLoading(false); - } - }, [deviceId, level, blocks, rangeStartMs]); - - useEffect(() => { - const target = sentinel.current; - if (!target || finished) return; - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) loadMore(); - }, - { root: scroller.current, rootMargin: "600px" }, - ); - observer.observe(target); - return () => observer.disconnect(); - }, [loadMore, finished, rows.length]); - - return ( -
    -
    - - - -
    -

    Loglar

    -

    - {rows.length} satır{finished ? "" : " ve devamı"} · seçili aralık -

    -
    - - {/* - Süzgeç bir EŞİKTİR, tam eşleşme değil: "warning+" seçildiğinde error - ve critical de listede kalır (lib/logs.ts). Etiketlerdeki artı işareti - bunu söylüyor — "warning" yazsaydı kullanıcı, süzgecin bir error'ı - gizlediğini sanabilirdi. - */} -
    - {FILTERS.map((f) => ( - - ))} -
    -
    - -
    - {error && ( -

    - Loglar okunamadı: {error} -

    - )} - - {rows.length === 0 && finished && !error && ( -

    - Bu aralıkta log yok. -

    - )} - -
      - {rows.map((row) => ( -
    • - - {logTime(row.measured_at, now)} - - - {row.level} - - - {row.source ?? "—"} - - - {row.message} - -
    • - ))} -
    - - {/* Sentinel: görünür alana yaklaşınca bir sonraki sayfayı tetikler. */} -
    - - {loading && ( -

    Yükleniyor…

    - )} -
    -
    - ); -} diff --git a/dashboard/app/(app)/devices/[id]/Timeline.tsx b/dashboard/app/(app)/devices/[id]/Timeline.tsx deleted file mode 100644 index 804ddc0..0000000 --- a/dashboard/app/(app)/devices/[id]/Timeline.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Zaman çizelgesi paneli (CLAUDE.md §9.5–§9.7). - * - * Üç ölçü ALT ALTA ve aynı x eksenini paylaşarak çizilir. Tek grafikte üst üste - * bindirmek de, ölçü seçtiren bir düğme koymak da denenebilirdi; ikisi de aynı - * soruyu cevapsız bırakırdı: "CPU fırladığında RAM ne yapıyordu?". Kara kutu - * okuyan biri tam olarak bunu arıyor. Üst üste bindirilse üç bant birbirini - * boyar, düğme konsa aynı anı üç kez ziyaret etmek gerekirdi. - * - * Üçü TEK istekle geliyor: metrics_buckets zaten üçünü birlikte döndürüyor. - * Ayrı ayrı çekmek aynı satırları üç kez taratır ve üç grafik birbirinden - * biraz farklı anlara ait olabilirdi. - * - * İmleç de ortak: bir grafiğin üstünde gezinirken üçünde birden aynı dikey - * çizgi duruyor ve üçü aynı kovanın değerlerini yazıyor. - * - * Aralık düğmeleri burada DEĞİL, toolbar'da (§9.8: "zaman aralığı TEKTİR" — - * aynı seçim log listesini de daraltıyor, o yüzden ikisinin ortağında durmalı). - * - * BU DİLİMDE YOK, bilerek: - * · Yakınlaştırma (§9.8) — alan seçme, geri, sıfırla. Ayrı dilim. - * · Çöküş işaretleri (§9.4) — gösterim biçimi henüz karara bağlanmadı (§9.13). - * · Canlı akış (§9.9) — aralık `anchor` ile sabitlenmiş durumda; Realtime - * dilimine kadar grafik yenilenmiyor. Log listesi de aynı şekilde çalışıyor, - * yani ekranın iki yarısı aynı ana bakıyor. - */ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - BUCKET_COUNT, - SERIES, - bucketWidthMs, - fetchBuckets, - formatDuration, - nearestIndex, - type MetricBucket, -} from "@/lib/metrics"; -import { axisTime } from "@/lib/time"; -import { IconActivity } from "@/components/icons"; -import { Chart } from "./Chart"; - -/** X ekseninde kaç etiket. Beşten fazlası dar ekranda üst üste biner. */ -const TICKS = 5; - -export function Timeline({ - deviceId, - ramTotalMb, - anchor, - rangeSeconds, -}: { - deviceId: string; - /** RAM grafiğinin tavanı. Cihazın tamamı değil sadece bu alan gerekiyor: - künye 10 saniyede bir yenilendiği için tüm nesneye bağlanmak, hiçbir şey - değişmese bile grafiğin geometrisini yeniden hesaplatırdı. */ - ramTotalMb: number | null; - /** - * Aralığın sabitlendiği an — saniyede bir tikleyen "now" DEĞİL. O olsaydı - * pencerenin iki ucu da her saniye kayar ve grafik durmadan yeniden çekilirdi. - */ - anchor: number; - rangeSeconds: number; -}) { - const [buckets, setBuckets] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [hoverTime, setHoverTime] = useState(null); - - const toMs = anchor; - const fromMs = anchor - rangeSeconds * 1000; - const spanMs = toMs - fromMs; - const widthMs = bucketWidthMs(fromMs, toMs, BUCKET_COUNT); - - /** - * Uçuştaki bir istek, kullanıcı aralığı değiştirdikten sonra dönebilir. - * Epoch olmadan 10 günlük yanıt, ekran çoktan 24 saate geçmişken grafiğe - * yazılırdı — kullanıcı seçtiğinden başka bir aralığa bakıyor olurdu. - */ - const epoch = useRef(0); - - useEffect(() => { - epoch.current += 1; - const mine = epoch.current; - - setLoading(true); - setHoverTime(null); - - fetchBuckets({ deviceId, fromMs, toMs }) - .then((rows) => { - if (mine !== epoch.current) return; - setBuckets(rows); - setError(null); - }) - .catch((e: unknown) => { - if (mine !== epoch.current) return; - setBuckets([]); - setError(e instanceof Error ? e.message : String(e)); - }) - .finally(() => { - if (mine === epoch.current) setLoading(false); - }); - }, [deviceId, fromMs, toMs]); - - const hoverIndex = useMemo( - () => (hoverTime == null ? null : nearestIndex(buckets, hoverTime)), - [hoverTime, buckets], - ); - - const onHoverTime = useCallback((t: number | null) => setHoverTime(t), []); - - const ticks = Array.from( - { length: TICKS }, - (_, i) => fromMs + (spanMs * i) / (TICKS - 1), - ); - - const hovered = hoverIndex == null ? null : buckets[hoverIndex]; - - return ( -
    -
    - - - -
    -

    Zaman çizelgesi

    - {/* - §9.6 madde 5 — ZORUNLU. Seyreltilmiş bir grafik, seyreltildiğini - söylemezse sessizce yalan söylemiş olur: kullanıcı ham veriye - baktığını sanar. Başlığın hemen altında, çünkü okunması grafiğe - bakmadan ÖNCE gerekiyor. - */} -

    - Her nokta = {formatDuration(widthMs)} · bant en düşük–en yüksek, - çizgi ortalama -

    -
    - {hovered && ( -

    - - {axisTime(Date.parse(hovered.bucket_start), spanMs)} - - {hovered.samples} ölçüm -

    - )} -
    - - {error && ( -

    - Grafik okunamadı: {error} -

    - )} - - {!error && loading && buckets.length === 0 && ( -
    - {SERIES.map((s) => ( -
    - ))} -
    - )} - - {!error && !loading && buckets.length === 0 && ( -

    - Bu aralıkta ölçüm yok. -

    - )} - - {!error && buckets.length > 0 && ( - <> -
    - {SERIES.map((series) => ( - - ))} -
    - -
    - {ticks.map((t) => ( - {axisTime(t, spanMs)} - ))} -
    - - )} -
    - ); -} diff --git a/dashboard/app/(app)/devices/[id]/page.tsx b/dashboard/app/(app)/devices/[id]/page.tsx index 9d50cb5..3968c58 100644 --- a/dashboard/app/(app)/devices/[id]/page.tsx +++ b/dashboard/app/(app)/devices/[id]/page.tsx @@ -6,10 +6,11 @@ * Yerleşim 70/30: geniş sol panel inceleme alanı (çizelge + loglar), dar sağ * panel sabit bağlam ve aksiyonlar. Sağ panel kaydırmada yerinde kalır. * - * Aralık (24 saat … 10 gün) burada DEĞİL, toolbar'da seçiliyor ve buraya - * kabuktan geliyor. §9.8: "zaman aralığı TEKTİR" — aynı seçim hem grafiği hem - * log listesini daraltıyor, dolayısıyla ikisinin ortak bir üstünde durması - * gerekiyordu. + * Aralık (24 saat … 10 gün) burada DEĞİL, toolbar'da seçiliyor; grafikte + * sürükleyerek yapılan ince ayar da (§9.8) aynı yerde. §9.8: "zaman aralığı + * TEKTİR" — aynı pencere hem grafiği hem log listesini daraltıyor, dolayısıyla + * ikisinin ortak bir üstünde (appState) durması gerekiyordu. Bu sayfa pencereyi + * ne tutuyor ne de aşağı taşıyor; iki bileşen de doğrudan oradan okuyor. */ "use client"; @@ -17,10 +18,11 @@ import { use, useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useApp } from "@/lib/appState"; import { fetchDevice, type DeviceDetail } from "@/lib/devices"; +import { errorMessage } from "@/lib/errors"; import { IconChevron } from "@/components/icons"; import { DetailPanel } from "./DetailPanel"; -import { Timeline } from "./Timeline"; -import { LogList } from "./LogList"; +import { Timeline } from "@/components/Timeline"; +import { LogList } from "@/components/LogList"; /** Künye yenileme — liste ekranıyla aynı ritim, agent'ın komut poll'ü. */ const REFRESH_MS = 10_000; @@ -31,7 +33,7 @@ export default function DeviceDetailPage({ params: Promise<{ id: string }>; }) { const { id } = use(params); - const { now, anchor, rangeSeconds } = useApp(); + const { now } = useApp(); const [device, setDevice] = useState(null); const [missing, setMissing] = useState(false); @@ -44,7 +46,7 @@ export default function DeviceDetailPage({ else setMissing(true); setError(null); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + setError(errorMessage(e)); } }, [id]); @@ -58,7 +60,7 @@ export default function DeviceDetailPage({
    - +
    )}
    diff --git a/dashboard/app/(app)/devices/page.tsx b/dashboard/app/(app)/devices/page.tsx index e4e0781..b799e77 100644 --- a/dashboard/app/(app)/devices/page.tsx +++ b/dashboard/app/(app)/devices/page.tsx @@ -1,59 +1,113 @@ /** - * Ekran 2 — Cihaz listesi (CLAUDE.md §9.3). + * Ekran 2 — Hosts (CLAUDE.md §9.3). * * Cevapladığı tek soru: "makinelerim iyi mi?" - * İki panel bölünmesi YOK (sabit bağlam olmadığı için); kartlar ızgarada. * - * Sayfa artık ne oturum kontrolü yapıyor ne de veri çekiyor: ikisi de kabukta + * Yerleşim referans 2'den: başlık + sağda özet sayılar, altında dört ölçü + * kartı, en altta cihazların kendisi. Sıra kabaca soruların sırası — "her şey + * yolunda mı" → "hangi ölçü sıkışmış" → "hangi makine". + * + * Sayfa ne oturum kontrolü yapıyor ne de veri çekiyor: ikisi de kabukta * (app/(app)/layout.tsx + lib/appState.tsx). Sidebar aynı listeyi gösterdiği ve * toolbar aynı listeden uyarı türettiği için veri TEK yerde duruyor — üç ayrı * sorgu olsaydı üçü birbirinden birkaç saniye farklı bir "şu an"a bakardı. */ "use client"; +import { useState } from "react"; import { useApp } from "@/lib/appState"; +import { deviceStatus } from "@/lib/devices"; +import { AddHostDialog } from "./AddHostDialog"; import { DeviceCard } from "./DeviceCard"; -import { IconPlus, IconSearch } from "@/components/icons"; +import { SummaryCards } from "./SummaryCards"; +import { IconPlus } from "@/components/icons"; + +/** Başlığın sağındaki üçlü sayaç (referans 2: Hosts / Online / Offline). */ +function Tally({ + value, + label, + tone, +}: { + value: number | string; + label: string; + tone?: string; +}) { + return ( +
    +

    + {value} +

    +

    {label}

    +
    + ); +} export default function DevicesPage() { - const { devices, error, now, query } = useApp(); + const { devices, error, now, hostFilter, reload } = useApp(); + const [adding, setAdding] = useState(false); - const needle = query.trim().toLocaleLowerCase("tr"); - const shown = devices?.filter((d) => - d.device_name.toLocaleLowerCase("tr").includes(needle), - ); + /* + * Üst çubuktaki host seçicisi burayı da daraltıyor (bir arama kutusunun + * yerini aldı, bkz. lib/appState.tsx). Seçili cihaz listeden silinmişse + * süzgeç hiçbir şey bırakmaz; o durumda tüm liste gösteriliyor — kullanıcıyı + * sebebi görünmeyen boş bir ekranla baş başa bırakmamak için. + */ + const filtered = hostFilter + ? devices?.filter((d) => d.id === hostFilter) + : devices; + const shown = filtered?.length ? filtered : devices; + + const online = + devices?.filter((d) => deviceStatus(d, now) === "online").length ?? 0; + const offline = + devices?.filter((d) => deviceStatus(d, now) === "offline").length ?? 0; return ( -
    -
    +
    + {/* --- başlık + özet sayaçlar -------------------------------------- */} +
    -

    Cihazlar

    +

    + Hosts +

    - Agent kurulu her makine ve son ölçümü. + Every machine running the agent, and its latest sample.

    - {/* - "+ Cihaz Ekle" collector'a POST /devices atacak. Collector'da CORS - middleware olmadığı için tarayıcıdan yapılan istek bugün bloklanıyor - (§9.13) — buton bu yüzden devre dışı. Çalışmayan bir butonu canlı - bırakmak, kullanıcıya sebebi görünmeyen bir hata göstermek olurdu. - */} - + + {devices && ( +
    + + + +
    + )}
    {error && (

    - Cihazlar okunamadı: {error} + Could not read hosts: {error}

    )} + {devices && devices.length > 0 && ( + + )} + + {/* --- liste başlığı ----------------------------------------------- */} +
    +

    + MACHINES +

    + +
    + {devices === null && !error && (
    {/* İskelet: boş ekran yerine kartın alacağı yer baştan ayrılıyor, @@ -68,19 +122,11 @@ export default function DevicesPage() { )} {devices?.length === 0 && ( -
    -

    Henüz cihaz yok.

    +
    +

    No hosts yet.

    - Bir cihaz eklendiğinde ve agent kurulduğunda burada görünür. -

    -
    - )} - - {shown?.length === 0 && devices && devices.length > 0 && ( -
    - -

    - {query} ile eşleşen cihaz yok. + Add a host, install the agent on that machine, and it will show up + here.

    )} @@ -92,6 +138,10 @@ export default function DevicesPage() { ))}
    )} + + {adding && ( + setAdding(false)} onCreated={reload} /> + )}
    ); } diff --git a/dashboard/app/(app)/inventory/page.tsx b/dashboard/app/(app)/inventory/page.tsx new file mode 100644 index 0000000..4e4a777 --- /dev/null +++ b/dashboard/app/(app)/inventory/page.tsx @@ -0,0 +1,357 @@ +/** + * Inventory — kenar çubuğundaki "Inventory" bölümünün sayfası. + * + * Metrics "şu anda ne oluyor"u, Inventory "bu makine NE"yi cevaplıyor. İkisi + * ayrı sayfa çünkü ayrı hızlarda değişiyorlar: metrik 5 saniyede bir, künye + * ayda bir (agent yalnızca DEĞİŞTİĞİNDE `POST /inventory` atıyor, §7). + * + * Künye şimdiye kadar yalnızca cihaz detayının sağ panelinde, tek makine için + * vardı. Cevaplanamayan soru şuydu: "hangi makinelerim hâlâ 22.04'te", + * "hangisinde GPU var", "hepsinde agent'ın son sürümü mü". Bunlar tek tek + * kartlara girip karşılaştırmayı gerektiriyordu; tablo tam olarak bunun için. + * + * Kendi sorgusunu açıyor (`fetchInventory`) — kabuğun 10 saniyede bir çektiği + * liste bu alanları taşımıyor ve taşımamalı da (lib/devices.ts → LIST_COLUMNS). + * Bu yüzden sayfa kabukla aynı anda değil, `reloadNonce` değiştiğinde ve ilk + * açılışta tazeleniyor: ayda bir değişen bir alan için 10 saniyelik anket + * israf olurdu. + * + * BİÇİM: makine başına bir KART, 13 sütunlu bir tablo değil (2026-08-31). + * + * İlk hâli geniş bir tabloydu ve kullanıcının itirazı yerindeydi: on üç sütun + * hiçbir ekrana sığmıyor, yatay kaydırma gerektiriyordu. Yatay kaydırma bir + * künye listesinde özellikle kötü — kullanıcı "bu makine ne" diye bakarken + * makinenin yarısını görüp diğer yarısı için sağa kayıyor, adı da ekrandan + * çıkıyordu. + * + * Kart, aynı bilgiyi DİKDÖRTGENE katlıyor: her alan sabit bir yerde duruyor, + * her kart aynı ızgarayı kullanıyor. Karşılaştırma sütun takip ederek değil, + * kartlar arasında AYNI HÜCREYE bakarak yapılıyor — "hangisinde GPU var" + * sorusu göz aşağı kayarken hep aynı noktadan okunuyor. + * + * Kartlar tek sütunda ve tam genişlikte: iki sütuna dizilseydi ızgara daralır, + * uzun CPU modeli yine kırpılır ve tek makineli hesapta kart ekranın yarısında + * asılı kalırdı. + */ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useApp } from "@/lib/appState"; +import { + deviceStatus, + fetchInventory, + type InventoryDevice, +} from "@/lib/devices"; +import { gb, localDateTime, relativeTime } from "@/lib/time"; +import { errorMessage } from "@/lib/errors"; +import { PageHeader, Tally } from "@/components/PageHeader"; +import { StatusPill } from "@/components/StatusPill"; +import { IconChevron } from "@/components/icons"; + +/** Boş bir alanın yerine tire — hücre boş kalırsa kart çökmüş gibi görünüyor. */ +function Cell({ value }: { value: string | number | null | undefined }) { + if (value == null || value === "") { + return ; + } + return <>{value}; +} + +/** + * Kartın içindeki tek künye alanı: üstte küçük büyük-harf etiket, altında değer. + * + * Etiket her zaman görünür — tablodaki gibi tek bir başlık satırına + * yaslanmıyor. Sebep kartın kendisi: on üç sütunluk bir tabloda başlık satırı + * bir kez yazılır, kartta ise her kutu kendi başına anlaşılmalı, yoksa + * "6.8.0-51-generic" yazan bir hücrenin ne olduğu ancak tahmin edilebilirdi. + */ +function Field({ + label, + value, + hint, + className = "", +}: { + label: string; + value: React.ReactNode; + hint?: React.ReactNode; + className?: string; +}) { + return ( +
    +

    + {label} +

    +

    {value}

    + {hint != null && ( +

    {hint}

    + )} +
    + ); +} + +export default function InventoryPage() { + const { error: shellError, now, hostFilter, reloadNonce } = useApp(); + + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(() => { + let cancelled = false; + fetchInventory() + .then((data) => { + if (cancelled) return; + setRows(data); + setError(null); + }) + .catch((e: unknown) => { + if (cancelled) return; + setError(errorMessage(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => load(), [load, reloadNonce]); + + const scoped = useMemo(() => { + if (!rows) return []; + return hostFilter ? rows.filter((d) => d.id === hostFilter) : rows; + }, [rows, hostFilter]); + + const scopeName = + hostFilter && rows + ? (rows.find((d) => d.id === hostFilter)?.device_name ?? null) + : null; + + /* + * "Reporting" = envanterini bir kez göndermiş makine sayısı. Yeni kurulan bir + * agent ilk turunu tamamlayana kadar satırda yalnızca ad ve anahtar var; + * sayaç bunu saklamak yerine söylüyor, kullanıcı boş hücreleri hata sanmasın. + */ + const reporting = scoped.filter((d) => d.cpu_model != null).length; + + return ( +
    + + {rows && ( +
    + + +
    + )} +
    + + {(error ?? shellError) && ( +

    + Could not read inventory: {error ?? shellError} +

    + )} + + {rows === null && !error && ( +
    + )} + + {rows && scoped.length === 0 && ( +

    + {rows.length === 0 ? ( + <> + No hosts yet. Add one from{" "} + + Hosts + + . + + ) : ( + "The selected host is not in this account any more." + )} +

    + )} + + {rows && scoped.length > 0 && ( +
    + {scoped.map((device) => ( +
    + {/* --- künye başlığı ------------------------------------- */} +
    + + {device.device_name} + + + + {/* İşletim sistemi başlıkta, ızgarada değil: "bu makine ne" + sorusunun ilk yarısı bu ve her kartın en üstünde aynı + yerde durması, kartlar arasında göz kaydırmayı ucuzlatıyor. */} + + + {device.arch && ` · ${device.arch}`} + + + + + +
    + + {/* + Izgara DÖRT sütun ve her kartta aynı: donanım üstte, sistem + altta. Alanların yeri sabit olmasaydı kart, tablonun çözdüğü + tek şeyi — aynı bilgiyi aynı yerde bulma — kaybederdi. + */} +
    + + + + } + hint={ + device.cpu_cores_logical == null ? ( + "—" + ) : ( + <> + {device.cpu_cores_logical} logical + {device.cpu_cores_physical != null && + ` · ${device.cpu_cores_physical} physical`} + + ) + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + {/* Açılış anı iki biçimde: üstte "ne kadar süredir ayakta" + (asıl merak edilen), altında tam tarih. */} + + } + hint={ + device.last_boot ? localDateTime(device.last_boot) : undefined + } + /> + + {/* Eklentiler config'te açılıyor (§4.3). Boş olması hata değil, + varsayılan — o yüzden "none" yazıyor, tire değil: tire + "bilinmiyor" demek. Rozetler sarmalanabildiği için alan + son satırın kalan üç sütununu birden kaplıyor: CPU zaten iki + sütun geniş olduğu için 4+4+4 = tam üç satır çıkıyor ve + kartın altında yarım kalmış bir sıra kalmıyor. */} +
    +

    + Add-ons +

    + {device.enabled_addons.length === 0 ? ( +

    none

    + ) : ( +
    + {device.enabled_addons.map((addon) => ( + + {addon} + + ))} +
    + )} +
    +
    +
    + ))} +
    + )} + + {rows && scoped.length > 0 && reporting < scoped.length && ( +

    + {scoped.length - reporting} of these hosts have not reported an + inventory yet — their agent uploads it once on start-up and only again + when the hardware actually changes, so empty cells here mean + “not yet received”, not “missing”. +

    + )} +
    + ); +} diff --git a/dashboard/app/(app)/layout.tsx b/dashboard/app/(app)/layout.tsx index 8bc30e0..065e26e 100644 --- a/dashboard/app/(app)/layout.tsx +++ b/dashboard/app/(app)/layout.tsx @@ -43,18 +43,25 @@ export default function AppLayout({ if (status !== "signedIn") { return (
    - Yükleniyor… + Loading…
    ); } return ( - + /* accounts.id = auth.users.id (§5), yani oturumdaki kullanıcı kimliği aynı + zamanda hesap kimliği. commands satırı eklerken account_id kolonu bununla + doldurulacak; RLS ikisinin eşit olmasını arıyor. */ + setMenuOpen(false)} /> - {/* Sidebar `fixed`; içerik geniş ekranda onun genişliği kadar içeri alınır. */} -
    + {/* Sidebar `fixed`; içerik geniş ekranda onun genişliği kadar içeri alınır. + Ölçü --sidebar-w değişkeninden geliyor (app/globals.css), yani çubuk + ve boşluk tek bir sayıyı paylaşıyor. */} +
    setMenuOpen(true)} /> -
    {children}
    + {/* Yatay boşluk 30px — referansta kenar çubuğunun çizgisi ile ilk + kartın kenarı arasındaki ölçü. */} +
    {children}
    ); diff --git a/dashboard/app/(app)/logs/page.tsx b/dashboard/app/(app)/logs/page.tsx new file mode 100644 index 0000000..09ccf70 --- /dev/null +++ b/dashboard/app/(app)/logs/page.tsx @@ -0,0 +1,79 @@ +/** + * Logs — kenar çubuğundaki "Logs" bölümünün sayfası. + * + * Neden ayrı bir sayfa: log listesi daha önce YALNIZCA cihaz detayında + * yaşıyordu. "Bu makinede ne oldu" sorusunun cevabı oradaydı ama "hesabımda + * bir yerlerde bir hata var mı" sorusunun cevabı hiçbir yerde yoktu — + * kullanıcı makineleri tek tek gezmek zorundaydı. Overview'ın beş satırlık + * kartı da yalnızca en yenileri gösteriyor, süzgeci ve sayfalaması yok. + * + * Liste bileşeni cihaz detayıyla AYNI (components/LogList): blok blok çekme + * (§9.5), ekrandan uzaklaşan blokları atma, seviye süzgeci ve saniyede bir + * boşalan canlı akış (§9.9) burada da aynen çalışıyor. Tek fark bir sütun — + * `deviceNames` verildiği için her satır hangi makineden geldiğini yazıyor ve + * o ad cihazın kendi sayfasına bağlanıyor. + * + * `deviceId = null` iken sorgu cihaz filtresi yazmıyor; kapsamı RLS + * (`account_id = auth.uid()`) tutuyor. Üst çubuktan bir makine seçilirse + * filtre geri geliyor — yani seçici burada da tüm sayfayı daraltıyor. + */ +"use client"; + +import { useMemo } from "react"; +import Link from "next/link"; +import { useApp } from "@/lib/appState"; +import { PageHeader } from "@/components/PageHeader"; +import { LogList } from "@/components/LogList"; + +export default function LogsPage() { + const { devices, error, hostFilter } = useApp(); + + const deviceNames = useMemo( + () => new Map((devices ?? []).map((d) => [d.id, d.device_name])), + [devices], + ); + + const scopeName = hostFilter + ? (deviceNames.get(hostFilter) ?? null) + : null; + + return ( +
    + + + {error && ( +

    + Could not read hosts: {error} +

    + )} + + {devices && devices.length === 0 ? ( +

    + No hosts yet. Add one from{" "} + + Hosts + {" "} + to start collecting logs. +

    + ) : ( + + )} +
    + ); +} diff --git a/dashboard/app/(app)/metrics/page.tsx b/dashboard/app/(app)/metrics/page.tsx new file mode 100644 index 0000000..76ea1d6 --- /dev/null +++ b/dashboard/app/(app)/metrics/page.tsx @@ -0,0 +1,122 @@ +/** + * Metrics — kenar çubuğundaki "Metrics" bölümünün sayfası. + * + * Neden ayrı bir sayfa: grafik daha önce YALNIZCA cihaz detayında yaşıyordu, + * yani "makinelerimin yükü nasıl" sorusuna bakmak için önce bir makine seçmek, + * sonra logların ve künyenin arasından grafiği bulmak gerekiyordu. Overview'ın + * küçük System Metrics kartı ise özet: 120 kova, tek çizgi kalınlığı, seçim + * yok. Burası inceleme yeri — tam çözünürlük (§9.7'nin ~1000 kovası), + * sürükleyerek yakınlaştırma (§9.8) ve iki ayrı eksen. + * + * İKİ GRAFİK: donanım (CPU/RAM/Disk, ortak yüzde ekseni) ve AĞ (gelen/giden). + * Ağın ayrı olması bir tercih değil zorunluluk — yüzdenin tavanı var, ağın yok; + * aynı eksene konsa biri diğerini ezerdi (lib/metrics.ts → networkTracks). + * + * Makine başına bir kart. Üst çubuktaki host seçicisi tek makineye kısıldığında + * yalnızca o kalıyor, yani seçici burada da tüm sayfayı daraltıyor (§9.1'in + * tek host/tek zaman kuralı). + * + * Burada log listesi YOK, künye YOK, aksiyon YOK: hepsinin kendi bölümü var. + * Kullanıcının şikâyeti tam olarak buydu — her şeyin tek yerde yığılması. + */ +"use client"; + +import { useMemo } from "react"; +import Link from "next/link"; +import { useApp } from "@/lib/appState"; +import { deviceStatus, type Device } from "@/lib/devices"; +import { PageHeader, Tally } from "@/components/PageHeader"; +import { Timeline } from "@/components/Timeline"; + +/** + * Aynı anda çizilecek en çok makine sayısı. + * + * Her kart kendi `metrics_buckets` çağrısını açıyor; sınırsız bırakmak, elli + * makineli bir hesapta tek sayfa açılışında elli sorgu demek olurdu. Altı, + * ekrana sığmayacak kadar çok ama tarayıcıyı zorlamayacak kadar az — üstü + * kesiliyor ve KESİLDİĞİ YAZILIYOR, çünkü sessizce kısaltılmış bir liste + * kullanıcıya makinelerinin bir kısmının veri göndermediğini düşündürürdü. + */ +const MAX_CHARTS = 6; + +export default function MetricsPage() { + const { devices, error, now, hostFilter } = useApp(); + + const scoped = useMemo(() => { + if (!devices) return []; + return hostFilter ? devices.filter((d) => d.id === hostFilter) : devices; + }, [devices, hostFilter]); + + const shown = scoped.slice(0, MAX_CHARTS); + const hidden = scoped.length - shown.length; + + const scopeName = + hostFilter && devices + ? (devices.find((d) => d.id === hostFilter)?.device_name ?? null) + : null; + + const online = scoped.filter((d) => deviceStatus(d, now) === "online").length; + + return ( +
    + + {devices && ( +
    + + +
    + )} +
    + + {error && ( +

    + Could not read hosts: {error} +

    + )} + + {devices && scoped.length === 0 && ( +

    + {devices.length === 0 ? ( + <> + No hosts yet. Add one from{" "} + + Hosts + {" "} + to start charting. + + ) : ( + "The selected host is not in this account any more." + )} +

    + )} + +
    + {shown.map((device) => ( + + ))} +
    + + {hidden > 0 && ( +

    + {hidden} more {hidden === 1 ? "host is" : "hosts are"} not charted + here. Pick one from the host filter above to see it. +

    + )} +
    + ); +} diff --git a/dashboard/app/(app)/overview/DataPath.tsx b/dashboard/app/(app)/overview/DataPath.tsx new file mode 100644 index 0000000..3081b3f --- /dev/null +++ b/dashboard/app/(app)/overview/DataPath.tsx @@ -0,0 +1,162 @@ +/** + * Veri yolu — Overview'ın ana paneli. + * + * Bu ekranın konusu artık cihazın CPU'su değil, SİSTEMİN kendisi: veri + * agent'tan çıkıp veritabanına düşüyor mu? Panel tam olarak bunu, yazma + * yolunun dört durağını (CLAUDE.md §1) tek satırda gösteriyor. + * + * Renkler bir SAĞLIK UCUNDAN gelmiyor — ne agent'ın ne collector'ın dashboard'a + * durum bildiren bir kanalı var; §9.1 gereği dashboard collector'a hiç istek + * atmıyor. Her durağın hâli saklanmış bir kanıttan çıkarılıyor ve o kanıt + * durağın altında yazılı duruyor (mantığı: lib/health.ts `dataPath`). + * + * Ok işaretinin rengi HEDEF durağın hâlini alıyor: kırık halka, kırılmanın + * gerçekleştiği yerden itibaren renk değiştirsin. Sabit gri oklar, üç yeşil + * bir kırmızı durağı bağlarken nerede koptuğunu göstermezdi. + * + * Filo şeridi de burada, ayrı bir panelde değil: yolun sol ucundaki Agent + * durağının somut karşılığı o makineler. "2 / 3 bildiriyor" yazan bir durak, + * hemen altında o üç makinenin adını göstermiyorsa yarım kalmış bir cümle. + */ +import { IconChart, IconCloud, IconDisk, IconServer } from "@/components/icons"; +import { Panel } from "@/components/Panel"; +import type { Device } from "@/lib/devices"; +import type { Hop, HopState } from "@/lib/health"; +import { FleetStrip } from "./FleetStrip"; + +const ICON: Record React.ReactElement> = { + agent: IconServer, + collector: IconCloud, + database: IconDisk, + dashboard: IconChart, +}; + +/** Durağın ne yaptığı — panelin ikinci işi: mimariyi anlatmak. */ +const ROLE: Record = { + agent: "Collects and spools on the host", + collector: "Authenticates the device key", + database: "Stores rows behind RLS", + dashboard: "Reads with your session", +}; + +/* Sınıf adları tam yazılı — Tailwind şablon ifadelerini tarayamaz. */ +const DOT: Record = { + ok: "bg-ok", + warn: "bg-warn", + down: "bg-danger", + unknown: "bg-faint", +}; + +const CHIP: Record = { + ok: "bg-ok-soft text-ok", + warn: "bg-warn-soft text-warn", + down: "bg-danger-soft text-danger", + unknown: "bg-panel-2 text-faint", +}; + +const ARROW: Record = { + ok: "text-ok", + warn: "text-warn", + down: "text-danger", + unknown: "text-faint", +}; + +function Arrow({ state }: { state: HopState }) { + return ( + + ); +} + +function Stop({ hop }: { hop: Hop }) { + const Icon = ICON[hop.key]; + + return ( +
    + + + + +
    +

    + + {hop.label} +

    + {/* Kanıt önce, rol sonra: kullanıcının aradığı "şu an ne oluyor", + mimari bilgisi ise arka planda kalsın. */} +

    {hop.detail}

    +

    {ROLE[hop.key]}

    +
    +
    + ); +} + +export function DataPath({ + hops, + devices, + now, +}: { + hops: Hop[]; + devices: Device[]; + now: number; +}) { + return ( + + Write path · device key → service key + + } + > +
    +
    + {hops.map((hop, i) => ( +
    + + {i < hops.length - 1 && } +
    + ))} +
    + +
    + +
    + + {/* + Panelin dürüstlük cümlesi. Yeşil bir zincir gören kullanıcı, bunun + bir sağlık ucundan değil saklanmış veriden çıkarıldığını bilmeli: + collector ayakta ama hiç istek almıyor olabilir, bu panel bunu + ayırt edemez ve edebiliyormuş gibi durmamalı. + */} +

    + Inferred from stored evidence — last contact, arrival timestamps and + this page’s own queries. There is no health endpoint on the + write path. +

    +
    +
    + ); +} diff --git a/dashboard/app/(app)/overview/FleetStrip.tsx b/dashboard/app/(app)/overview/FleetStrip.tsx new file mode 100644 index 0000000..7655f0f --- /dev/null +++ b/dashboard/app/(app)/overview/FleetStrip.tsx @@ -0,0 +1,64 @@ +/** + * Filo şeridi — veri yolu panelinin alt bandı. + * + * Kendi paneli DEĞİL, bilerek. "Kaydırmasız tek ekran" hedefi için her panel + * başlığı 74 piksel; filo, yolun sol ucunda duran Agent durağının somut hâli + * olduğu için aynı kutunun içinde yaşıyor. Ayrı bir panel olsaydı, aynı şeyi + * iki kez başlıklandırıp ekranı taşırmış olurduk. + * + * Hosts sayfasının kopyası da değil: orada kart başına CPU/RAM/Disk çubukları, + * künye ve eylemler var. Burada makine başına yalnızca üç şey duruyor — adı, + * hâli ve en son ne zaman konuştuğu. + * + * Yatay kaydırma bilerek: filo büyüdükçe şerit SATIRA yayılmıyor, kayıyor. + * Sarmalasaydı üç makinede 56 piksel, on beşte 180 piksel yer kaplar ve ekran + * yüksekliği cihaz sayısına bağlı hâle gelirdi. + */ +import Link from "next/link"; +import { StatusDot } from "@/components/StatusPill"; +import { deviceStatus, STATUS_LABEL, type Device } from "@/lib/devices"; +import { relativeTime } from "@/lib/time"; + +export function FleetStrip({ + devices, + now, +}: { + devices: Device[]; + now: number; +}) { + if (devices.length === 0) { + return ( +

    + No hosts yet — add one from the Hosts page to start recording. +

    + ); + } + + return ( +
    +
      + {devices.map((device) => { + const status = deviceStatus(device, now); + return ( +
    • + + + + + {device.device_name} + + + {STATUS_LABEL[status]} · {relativeTime(device.last_seen, now)} + + + +
    • + ); + })} +
    +
    + ); +} diff --git a/dashboard/app/(app)/overview/FlushEvents.tsx b/dashboard/app/(app)/overview/FlushEvents.tsx new file mode 100644 index 0000000..91a020b --- /dev/null +++ b/dashboard/app/(app)/overview/FlushEvents.tsx @@ -0,0 +1,123 @@ +/** + * Acil gönderimler — ürünün asıl iddiasının kanıt panosu. + * + * Agent bir eşiği aştığında (cpu>90, ram>90, disk>95 ya da error/critical bir + * log — §7 `flush.py`) sıradaki gönderim turunu BEKLEMEZ, spool'u anında + * boşaltır ve o anın süreç fotoğrafını `crash_snapshots`'a yazar. Buradaki her + * satır, verinin çökmeden önce dışarı taşındığı bir andır. + * + * Sayaç neden `crash_snapshots`: flush her tetiklendiğinde bir satır oluşuyor, + * `crash_processes` eklentisi kapalı olsa bile — eklenti yalnızca `processes` + * dizisini dolduruyor. Yani satır sayısı olayın kendisini sayıyor. + * + * Panel hiçbir zaman ALARM RENGİNDE değil. Flush bir arıza değil, sistemin + * doğru davranışı; kırmızı yakmak çalışan bir mekanizmayı hata gibi + * göstermek olurdu. + */ +import Link from "next/link"; +import { Panel, PanelNote } from "@/components/Panel"; +import { relativeTime } from "@/lib/time"; +import type { FlushEvents as FlushData } from "@/lib/health"; + +/** + * Tetikleyici adları agent'tan geldiği gibi geliyor (§4.2: cpu | ram | disk | + * log). Renkler ölçü kimlikleriyle aynı — kullanıcı zaten CPU'yu mor, RAM'i + * yeşil, diski turuncu olarak öğrendi; burada başka renk kullanmak aynı şeye + * ikinci bir kimlik vermek olurdu. + */ +const REASON: Record = { + cpu: { label: "CPU", chip: "bg-cpu-soft text-cpu" }, + ram: { label: "RAM", chip: "bg-ram-soft text-ram" }, + disk: { label: "Disk", chip: "bg-disk-soft text-disk" }, + log: { label: "Log", chip: "bg-danger-soft text-danger" }, + unknown: { label: "Unknown", chip: "bg-panel-2 text-faint" }, +}; + +function reasonOf(key: string) { + return REASON[key] ?? REASON.unknown; +} + +export function FlushEventsPanel({ + flushes, + deviceNames, + now, + loading, +}: { + flushes: FlushData | null; + deviceNames: Map; + now: number; + loading: boolean; +}) { + return ( + threshold crossed + } + > + {loading && !flushes ? ( + Loading… + ) : !flushes || flushes.total === 0 ? ( + + No thresholds crossed in this range — nothing needed an early ship. + + ) : ( +
    +
    + {Object.entries(flushes.byReason) + .sort((a, b) => b[1] - a[1]) + .map(([key, count]) => { + const reason = reasonOf(key); + return ( + + {reason.label} · {count} + + ); + })} +
    + +
      + {flushes.recent.map((event) => ( +
    • + + {reasonOf(event.trigger_reason ?? "unknown").label} + + {/* + Cihaz adı bağlantı: kullanıcının bir sonraki adımı zaten + "o an o makinede ne oldu" — detay ekranı grafiği ve logları + aynı pencereye kısıyor (§9.8). + */} + + {deviceNames.get(event.device_id) ?? "Unknown host"} + + + {relativeTime(event.measured_at, now)} + +
    • + ))} +
    + + {/* + Rozetler İNDİRİLEN satırlardan sayılıyor, penceredeki hepsinden + değil (lib/health.ts). Toplam daha büyükse bunu söylemek zorundayız; + yoksa dağılım bütünün oranıymış gibi okunurdu (§9.6 madde 5). + */} + {flushes.total > flushes.recent.length && ( +

    + Showing the {flushes.recent.length} most recent of {flushes.total}. +

    + )} +
    + )} +
    + ); +} diff --git a/dashboard/app/(app)/overview/HealthStrip.tsx b/dashboard/app/(app)/overview/HealthStrip.tsx new file mode 100644 index 0000000..434c33f --- /dev/null +++ b/dashboard/app/(app)/overview/HealthStrip.tsx @@ -0,0 +1,172 @@ +/** + * Overview'ın başlık şeridi — dört rakam, altındaki dört panelin özeti. + * + * §9.11.2'de bu şerit CPU/RAM/Disk/Ağ ortalamalarına kilitlenmişti. Kullanıcı + * 2026-08-31'de Overview'ın konusunu değiştirdi: ekran artık cihazların + * ölçümlerini değil SİSTEMİN kendisini anlatıyor. O dört ortalama zaten + * Metrics sayfasında ve cihaz kartlarında duruyordu; burada tekrar olurlardı. + * + * Referans 2'nin deseni korunuyor: üstte tek satırlık rakamlar, altında o + * rakamı açan panel. Şerit "iyi mi?" sorusuna, paneller "neden?" sorusuna + * cevap veriyor. + * + * Her kart HANGİ TEMELE dayandığını kendi altında yazıyor (§9.6 madde 5) — + * "1.4 s" tek başına üç örnekten de beş yüz örnekten de çıkmış olabilir. + */ +import { IconActivity, IconAlert, IconClock, IconServer } from "@/components/icons"; +import { + coverageDays, + formatLag, + LAG_OK_MS, + LAG_WARN_MS, + type FlushEvents, + type IngestLag, + type RetentionState, +} from "@/lib/health"; + +type Tone = "ok" | "warn" | "danger" | "neutral"; + +/* + * Sınıf adları TAM yazılı, şablon yok: Tailwind kaynağı METİN olarak tarıyor, + * `bg-${tone}-soft` gibi bir ifadeden üretilecek sınıfı göremez ve o rengi + * derlemeye hiç koymaz. + */ +const CHIP: Record = { + ok: "bg-ok-soft text-ok", + warn: "bg-warn-soft text-warn", + danger: "bg-danger-soft text-danger", + neutral: "bg-accent-soft text-accent", +}; + +const VALUE_TONE: Record = { + ok: "text-fg", + warn: "text-warn", + danger: "text-danger", + neutral: "text-fg", +}; + +function Stat({ + icon: Icon, + title, + value, + unit, + note, + tone, +}: { + icon: (p: { className?: string }) => React.ReactElement; + title: string; + value: string; + unit?: string; + note: string; + tone: Tone; +}) { + return ( +
    +
    + + + +

    {title}

    +
    + +

    + {value} + {unit && {unit}} +

    +

    {note}

    +
    + ); +} + +export function HealthStrip({ + reporting, + hosts, + lag, + flushes, + retention, + now, +}: { + reporting: number; + hosts: number; + lag: IngestLag | null; + flushes: FlushEvents | null; + retention: RetentionState | null; + now: number; +}) { + /* + * Cihaz yokken rozet YEŞİL DEĞİL. "0 / 0 bildiriyor" teknik olarak doğru ama + * hiçbir şey kanıtlamıyor; kurulumu hiç yapmamış kullanıcıya sistemin + * çalıştığını söylemiş olurduk (lib/health.ts'teki `unknown` kuralı). + */ + const hostTone: Tone = + hosts === 0 ? "neutral" : reporting === hosts ? "ok" : reporting > 0 ? "warn" : "danger"; + + const lagTone: Tone = + !lag || lag.samples === 0 + ? "neutral" + : lag.medianMs <= LAG_OK_MS + ? "ok" + : lag.medianMs <= LAG_WARN_MS + ? "warn" + : "danger"; + + /* + * Acil gönderim kartı hiçbir zaman KIRMIZI olmuyor. Flush bir arıza değil, + * ürünün çalıştığının kanıtı: eşik aşıldığında veri çökmeden önce dışarı + * taşındı demek. Kırmızı yakmak, sistemin doğru davranışını hata gibi + * göstermek olurdu. Sıfır da bir haber değil — sakin bir makine. + */ + const flushTone: Tone = flushes && flushes.total > 0 ? "warn" : "neutral"; + + const covered = retention ? coverageDays(retention.oldestReceivedAt, now) : null; + const retentionDays = retention?.retentionDays ?? null; + + return ( +
    + + + 0 ? formatLag(lag.medianMs) : "—"} + note={ + lag && lag.samples > 0 + ? `Median over ${lag.samples} samples` + : "No measurements yet" + } + tone={lagTone} + /> + + + + +
    + ); +} diff --git a/dashboard/app/(app)/overview/IngestLag.tsx b/dashboard/app/(app)/overview/IngestLag.tsx new file mode 100644 index 0000000..17f8089 --- /dev/null +++ b/dashboard/app/(app)/overview/IngestLag.tsx @@ -0,0 +1,98 @@ +/** + * Gecikme paneli — "ölçüm ile kayıt arasında ne kadar var?" + * + * TraceBox'ın vaadi "makine çökmeden ÖNCE veri dışarıda olsun" (CLAUDE.md §0). + * Bu panel o vaadin ölçüsü: agent'ın damgaladığı `measured_at` ile sunucunun + * damgaladığı `received_at` arasındaki fark. İkisi de zaten her satırda duruyor + * (§5), yani ölçüm için ek bir şey saklanmıyor. + * + * Üç sayı birlikte gösteriliyor çünkü tek başına hiçbiri yeterli değil: + * medyan tipik hâli, p95 kötü günü, en yüksek de en kötü anı anlatıyor. Yalnız + * medyan gösterilseydi, on dakikalık tek bir tıkanma ekranda hiç görünmezdi — + * §9.6 madde 6'nın grafik için koyduğu kuralın aynısı ("ortalama tek başına + * yalan söyler") burada sayılara uygulanmış hâli. + */ +import { Panel, PanelNote } from "@/components/Panel"; +import { + formatLag, + LAG_OK_MS, + LAG_WARN_MS, + type IngestLag as IngestLagData, +} from "@/lib/health"; + +function Figure({ + label, + value, + tone = "text-fg", +}: { + label: string; + value: string; + tone?: string; +}) { + return ( +
    +

    + {value} +

    +

    {label}

    +
    + ); +} + +export function IngestLagPanel({ + lag, + loading, +}: { + lag: IngestLagData | null; + loading: boolean; +}) { + return ( + measured → stored + } + > + {loading && !lag ? ( + Measuring… + ) : !lag || lag.samples === 0 ? ( + No rows to measure yet. + ) : ( +
    +
    +
    +
    +
    +
    + +

    + Over the last {lag.samples} metric rows. +

    + + {/* + Saat kayması gizlenmiyor. `received_at < measured_at` fiziksel + olarak imkânsız — veri varmadan ölçülemez — ve görülüyorsa cihazın + saati ileri demektir. O satırlar dağılımdan çıkarıldı; çıkarıldığını + SÖYLEMESEYDİK ekrandaki gecikme olduğundan iyi görünürdü. + */} + {lag.skewed > 0 && ( +

    + {lag.skewed} row{lag.skewed === 1 ? "" : "s"} arrived before they + were measured — host clock is ahead. Excluded from the figures. +

    + )} +
    + )} +
    + ); +} diff --git a/dashboard/app/(app)/overview/Retention.tsx b/dashboard/app/(app)/overview/Retention.tsx new file mode 100644 index 0000000..a4c1062 --- /dev/null +++ b/dashboard/app/(app)/overview/Retention.tsx @@ -0,0 +1,92 @@ +/** + * Saklama penceresi — "kara kutu ne kadar geriye gidiyor?" + * + * `accounts.retention_days` bir POLICY: kullanıcı değiştiremez (§5). Her gece + * 00:00 UTC'de pg_cron, penceresi dolmuş satırları siliyor (`retention.sql`). + * Panel bu pencerenin ne kadarının DOLU olduğunu gösteriyor: en eski kaydın + * yaşı, sınıra oranla. + * + * Ölçüt `received_at`, `measured_at` değil — silme de onunla yapılıyor. İki + * ayrı "eski" tanımı olsaydı ekrandaki çubuk ile cron'un sildiği satır + * uyuşmaz, pencere dolu görünürken boşalabilirdi (§5'teki gerekçe). + * + * Çubuk bir UYARI değil: dolmak beklenen davranış. On günlük pencere dolduğunda + * sistem bozulmuyor, tasarlandığı gibi çalışıyor — en eski gün düşüyor, yenisi + * giriyor. Bu yüzden renk kehribara/kırmızıya dönmüyor. + */ +import { Panel, PanelNote } from "@/components/Panel"; +import { coverageDays, type RetentionState } from "@/lib/health"; +import { localDateTime } from "@/lib/time"; + +function Count({ label, rows }: { label: string; rows: number }) { + return ( +
    +

    + {rows.toLocaleString("en-US")} +

    +

    {label}

    +
    + ); +} + +export function RetentionPanel({ + retention, + now, + loading, +}: { + retention: RetentionState | null; + now: number; + loading: boolean; +}) { + const covered = retention + ? coverageDays(retention.oldestReceivedAt, now) + : null; + + return ( + + {retention.retentionDays}-day policy + + ) + } + > + {loading && !retention ? ( + Loading… + ) : !retention || covered == null ? ( + Nothing recorded yet. + ) : ( +
    +

    + {covered.toFixed(1)} + + of {retention.retentionDays} days recorded + +

    + +
    +
    +
    + +
    + + + +
    + +

    + Oldest row {localDateTime(retention.oldestReceivedAt)}. Older rows + are dropped nightly at 00:00 UTC. +

    +
    + )} + + ); +} diff --git a/dashboard/app/(app)/overview/page.tsx b/dashboard/app/(app)/overview/page.tsx new file mode 100644 index 0000000..82de631 --- /dev/null +++ b/dashboard/app/(app)/overview/page.tsx @@ -0,0 +1,191 @@ +/** + * Ekran 0 — Overview: "kara kutu kayıt yapıyor mu?" + * + * Bu sayfa 2026-08-31'de KONU DEĞİŞTİRDİ. Önceki hâli referans 2'nin birebir + * kopyasıydı: KPI şeridi, sistem metrikleri grafiği, son loglar, host tablosu, + * uyarılar, en yoğun makineler. Kullanıcının itirazı iki katmanlıydı — ekran + * kaydırılmadan bitmiyordu ve gösterdiği her şeyin zaten kendi sayfası vardı + * (Metrics, Logs, Hosts, Alerts). Yani Overview bir özet değil, dört sayfanın + * kısaltılmış tekrarıydı. + * + * Yeni konu: cihazların ölçümleri değil SİSTEMİN kendisi. Beş bölüm, kullanıcı + * tarafından seçildi — veri yolu · gecikme · acil gönderimler · saklama · + * filo şeridi. Hiçbiri başka sayfada yok, hepsi tek soruyu cevaplıyor. + * + * Bunun bir sonucu: §9.11.2'nin "KPI şeridi CPU/RAM/Disk/Ağ ortalamalarıdır" + * kilidi bu sayfada geçersiz. Şerit hâlâ orada, ama artık boru hattını + * ölçüyor (HealthStrip). + * + * Yerleşim tek ekrana sığacak biçimde kuruldu: başlık → dört rakam → veri yolu + * (filo şeridi onun alt bandında) → üç panel. Kaydırma gerektiren bir "özet" + * ekranı kendi işini yapmıyor demektir; bu yüzden filo kendi paneline değil + * veri yolunun içine kondu ve acil gönderim listesi dört satırla sınırlandı. + */ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useApp } from "@/lib/appState"; +import { deviceStatus, type Device } from "@/lib/devices"; +import { + dataPath, + fetchFlushEvents, + fetchIngestLag, + fetchRetention, + type FlushEvents, + type IngestLag, + type RetentionState, +} from "@/lib/health"; +import { DataPath } from "./DataPath"; +import { FlushEventsPanel } from "./FlushEvents"; +import { HealthStrip } from "./HealthStrip"; +import { IngestLagPanel } from "./IngestLag"; +import { RetentionPanel } from "./Retention"; + +export default function OverviewPage() { + const { devices, error, now, hostFilter, timeWindow, reloadNonce } = useApp(); + + const [lag, setLag] = useState(null); + const [retention, setRetention] = useState(null); + const [flushes, setFlushes] = useState(null); + const [loadingState, setLoadingState] = useState(true); + const [loadingFlushes, setLoadingFlushes] = useState(true); + + /** Üst çubuktaki seçici tüm sayfayı daraltıyor (§9.1'in tek host kuralı). */ + const scoped = useMemo(() => { + if (!devices) return []; + return hostFilter ? devices.filter((d) => d.id === hostFilter) : devices; + }, [devices, hostFilter]); + + const { from, to } = timeWindow; + + /* + * İKİ ayrı effect, çünkü bağımlılıkları farklı. + * + * Gecikme ve saklama zaman penceresine BAKMIYOR: ikisi de "şu an sistem ne + * hâlde" sorusunu cevaplıyor, kullanıcının incelediği aralığı değil. Üstelik + * saklama sorgusu üç tabloda `count: exact` çalıştırıyor — kullanıcı her + * aralık düğmesine bastığında yeniden saymak, hiçbir şeyi değiştirmeyen + * pahalı bir tarama olurdu. + */ + useEffect(() => { + let alive = true; + setLoadingState(true); + + Promise.all([ + fetchIngestLag({ deviceId: hostFilter }), + fetchRetention({ deviceId: hostFilter }), + ]) + .then(([nextLag, nextRetention]) => { + if (!alive) return; + setLag(nextLag); + setRetention(nextRetention); + }) + .catch(() => { + // Sessiz düşüş: hata bandı zaten cihaz sorgusunun hatasını gösteriyor + // ve aynı RLS'in altında koşan bu sorgular ondan bağımsız bozulmaz. + if (alive) { + setLag(null); + setRetention(null); + } + }) + .finally(() => { + if (alive) setLoadingState(false); + }); + + return () => { + alive = false; + }; + }, [hostFilter, reloadNonce]); + + useEffect(() => { + let alive = true; + setLoadingFlushes(true); + + fetchFlushEvents({ deviceId: hostFilter, fromMs: from, toMs: to }) + .then((next) => { + if (alive) setFlushes(next); + }) + .catch(() => { + if (alive) setFlushes(null); + }) + .finally(() => { + if (alive) setLoadingFlushes(false); + }); + + return () => { + alive = false; + }; + }, [hostFilter, from, to, reloadNonce]); + + const deviceNames = useMemo( + () => new Map((devices ?? []).map((d) => [d.id, d.device_name])), + [devices], + ); + + /* + * "Bildiriyor" ölçütü çevrimdışı OLMAMAK, çevrimiçi olmak değil. + * Duraklatılmış bir cihaz veri göndermez ama komut poll'ünü sürdürür (§7), + * yani boru hattı onun için de ayakta. Silinme bekleyen cihaz da öyle. + */ + const reporting = scoped.filter( + (d) => deviceStatus(d, now) !== "offline", + ).length; + + const hops = useMemo( + () => + dataPath({ + devices: devices == null ? null : scoped, + now, + error, + lastReceivedAt: lag?.lastReceivedAt ?? null, + }), + [devices, scoped, now, error, lag], + ); + + return ( +
    +
    +

    + Overview +

    +

    + How the recorder is doing — from the host all the way to this page. +

    +
    + + {error && ( +

    + Could not read hosts: {error} +

    + )} + +
    + + + + +
    + + + +
    +
    +
    + ); +} diff --git a/dashboard/app/(app)/settings/page.tsx b/dashboard/app/(app)/settings/page.tsx new file mode 100644 index 0000000..c137007 --- /dev/null +++ b/dashboard/app/(app)/settings/page.tsx @@ -0,0 +1,276 @@ +/** + * Settings — kenar çubuğundaki "Settings" bölümünün sayfası. + * + * Burada DÜZENLENEBİLİR alan neredeyse yok ve bu bir eksiklik değil, §2'nin + * "config = insan sınırı, policy = sistem sınırı" ayrımının doğrudan sonucu: + * + * - Toplama aralıkları, eşikler, spool sınırları → CONFIG. İnsan sınırı ama + * o insan makinenin sahibi ve ayar makinede duruyor (/etc/tracebox/config.toml, + * §4.3). Dashboard'dan yazılamaz — yazılabilseydi tek yazar kuralı kırılır, + * agent'ın diskteki dosyası ile bulutun kopyası ayrışırdı. + * - Saklama süresi ve plan → POLICY. Sistem sınırı; kullanıcı kendi faturasını + * kendi büyütemesin diye salt okunur. + * - Tema → gerçekten kullanıcının, gerçekten burada. + * + * Bu yüzden sayfa bir form değil, bir KÜNYE: hangi ayarın nerede yaşadığını ve + * neden orada olduğunu söylüyor. Söylemeseydi kullanıcı düzenlenemeyen alanlara + * bakıp arayüzün yarım kaldığını sanırdı. + */ +"use client"; + +import { useEffect, useState } from "react"; +import { useApp } from "@/lib/appState"; +import { useTheme } from "@/lib/theme"; +import { fetchAccount, type Account } from "@/lib/account"; +import { supabase } from "@/lib/supabase"; +import { localDateTime } from "@/lib/time"; +import { errorMessage } from "@/lib/errors"; +import { PageHeader } from "@/components/PageHeader"; +import { IconLogout, IconMoon, IconSun } from "@/components/icons"; + +/** Künye satırı: solda etiket + gerekçe, sağda değer. */ +function Row({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
    +
    +

    {label}

    + {hint &&

    {hint}

    } +
    +
    {children}
    +
    + ); +} + +function Card({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
    +
    +

    {title}

    +

    {description}

    +
    + {children} +
    + ); +} + +/** Salt okunur, seçilebilir teknik değer (UUID, URL). */ +function Mono({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export default function SettingsPage() { + const { email, accountId, devices } = useApp(); + const { theme, setTheme } = useTheme(); + + const [account, setAccount] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchAccount() + .then((data) => !cancelled && setAccount(data)) + .catch((e: unknown) => { + if (!cancelled) setError(errorMessage(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + const collectorUrl = process.env.NEXT_PUBLIC_COLLECTOR_URL ?? null; + + return ( +
    + + + {error && ( +

    + Could not read the account row: {error} +

    + )} + +
    + {/* --- hesap --------------------------------------------------- */} + + + {email} + + + {accountId} + + + + {account?.plan ?? "—"} + + + + + {account ? localDateTime(account.created_at) : "—"} + + + + + {devices ? devices.length : "—"} + + + + + {/* --- policy -------------------------------------------------- */} + + + + {account ? `${account.retention_days} days` : "—"} + + +
    +

    + TraceBox is a black box, not an archive: it answers{" "} + what happened just before this + machine went down, and that question has a short shelf + life. Letting each account extend its own window would quietly + turn a fixed storage bill into an open-ended one, so the window is + set by the system rather than by you. +

    +
    +
    + + {/* --- agent tarafındaki ayarlar -------------------------------- */} + +
    +

    + Collection interval, send interval, flush thresholds and spool + limits are read from{" "} + /etc/tracebox/config.toml on the host itself. The + agent re-reads that file on every tick, so an edit takes effect + without a restart — and no restart is needed here either, because + the dashboard never writes it. +

    +

    + Keeping it one-way is what makes the agent trustworthy while + offline: the machine’s own file is the only source of truth + for how it behaves, whether or not it can reach the cloud. What + the dashboard can do is queue a + command — pause, resume or delete — which the agent picks up on + its next poll. Those buttons are on each host’s own page. +

    +
    +
    + + {/* --- görünüm -------------------------------------------------- */} + + +
    + {( + [ + { value: "light", label: "Light", Icon: IconSun }, + { value: "dark", label: "Dark", Icon: IconMoon }, + ] as const + ).map(({ value, label, Icon }) => ( + + ))} +
    +
    +
    + + {/* --- bağlantılar ---------------------------------------------- */} + + + {collectorUrl ? ( + {collectorUrl} + ) : ( + not configured + )} + + + {process.env.NEXT_PUBLIC_SUPABASE_URL ?? "—"} + + + + {/* --- oturum ---------------------------------------------------- */} + + + + + +
    + +
    + ); +} diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index fc24ef8..bd5a943 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -1,56 +1,172 @@ @import "tailwindcss"; /* - * Görsel dil — dashboard/example/TraceBox Dashboard Tasarım Planı.md. + * İKİ TEMA — açık varsayılan (CLAUDE.md §9.11.2). * - * Renkler burada BİR KEZ tanımlanır; bileşenlerde ham hex kullanılmaz. Sebep: - * landing (§9.12) ile dashboard aynı dili konuşacak. Renk her dosyaya elle - * yazılsaydı, marka tonu değiştiğinde onlarca yerde ayrı ayrı kayardı. + * Açık temanın HER DEĞERİ `dashboard/example/2` görselinden ölçüldü: görsel + * ham piksele çevrilip (ffmpeg → rgb24) yüzeylerin ortancası, metinlerin en + * koyu üçte birinin ortancası okundu. Tahmin edilen tek renk yok; yorumlanan + * tek şey, ölçülen değerin en yakın standart tona yuvarlanması. * - * Planın §2 maddesi tek bir kural koyuyor: "Renkleri yalnızca dekorasyon için - * değil, DURUM ve METRİC ayrımı için kullan." Bu yüzden paletin yarısı isimden - * anlam taşıyor — --color-cpu bir mavi tonu değil, "CPU şu renkle çizilir" - * sözleşmesi. Bileşen dosyaları hangi maviyi seçeceğine karar vermez. + * Nasıl çalışıyor: Tailwind v4'te tema değişkenleri sıradan CSS custom + * property. `@theme` bloğu onları `:root`a yazıyor ve `bg-panel`, `text-muted` + * gibi sınıflar `var(--color-panel)` üretiyor. Koyu tema bu yüzden ikinci bir + * sınıf kümesi DEĞİL — yalnızca aynı değişkenlerin `[data-theme="dark"]` + * altında yeniden tanımlanması. Bileşen dosyalarında tek bir `dark:` öneki + * yok; olsaydı her renk iki yerde yazılır ve biri sessizce geride kalırdı. */ @theme { - /* --- yüzeyler: zemin en koyu, panel bir ton açık (plan §1) ------------- */ - --color-bg: #060a14; - --color-bg-soft: #080d1a; /* sidebar / toolbar — zeminden ayrılsın */ - --color-panel: #0e1424; /* kart ve panel gövdesi */ - --color-panel-2: #141c30; /* panel İÇİ vurgusuz yüzey: input, satır, çubuk yatağı */ - --color-line: #1e2740; /* ince ve DÜŞÜK KONTRASTLI çerçeve (plan §1) */ + /* --- yüzeyler --------------------------------------------------------- + * Referansta sayfa, kenar çubuğu ve üst çubuk AYNI renk (#fcfcfe); ayrımı + * yapan tek şey 1 piksellik çizgi. Öne çıkan tek yüzey kart: saf beyaz. + * Önceki turda zemin belirgin biçimde daha koyuydu (#f5f6f9) — kartları + * ayırmak için gereğinden fazla kontrast, referansta yok. + */ + --color-bg: #fcfcfe; + --color-bg-soft: #fcfcfe; + --color-panel: #ffffff; + --color-panel-2: #f4f5f8; /* panel İÇİ vurgusuz yüzey: input, çubuk yatağı */ + --color-line: #eef0f5; /* --- metin ------------------------------------------------------------- */ + --color-fg: #101828; + --color-muted: #475467; + --color-faint: #98a2b3; /* eksen etiketi, log kaynağı — muted'in altı */ + + /* --- birincil aksiyon: mor --------------------------------------------- */ + --color-accent: #6647ef; /* ölçüldü: seçili menü yazısı */ + --color-accent-strong: #5636e0; + --color-accent-soft: #f1eefd; /* ölçüldü: seçili menü hapının zemini */ + + /* --- durum renkleri ---------------------------------------------------- + * Her birinin bir de "soft" zemini var: referansın rozetleri (Healthy, + * ERROR, WARN, INFO, Critical, Warning) dolu renk değil, açık zemin üstünde + * doygun yazı. `bg-danger/10` ile taklit edilebilirdi ama alfa harmanı + * ölçülen zeminleri birebir vermiyor. + */ + --color-ok: #17b26a; + --color-ok-soft: #e3f8ec; + --color-warn: #f79009; + --color-warn-soft: #fdead3; + --color-danger: #f04438; + --color-danger-soft: #fdeae9; + --color-info: #3b82f6; + --color-info-soft: #ebf2fe; + + /* + * --- ölçü renkleri ----------------------------------------------------- + * Referans 2'nin dört kimliği: CPU mor, bellek yeşil, disk TURUNCU, ağ mavi. + * Disk kehribardan (#f59e0b) turuncuya alındı — görselde ölçülen çizgi ve + * ikon kutusu turuncu. + * + * Turuncu disk ile kehribar uyarı hâlâ yakın akraba. Bu yüzden "eşiğe + * yaklaştı" sinyali YALNIZCA çubuk rengine bırakılmadı: sayı da kehribara + * döner ve yanında bir uyarı üçgeni belirir (DeviceCard). Renk körü bir + * kullanıcı için de, turuncu bir çubuğa bakan biri için de sinyal ayakta. + */ + --color-cpu: #7c5cf6; + --color-cpu-soft: #efeafe; + --color-ram: #16b364; + --color-ram-soft: #e5f8ed; + --color-disk: #f97316; + --color-disk-soft: #fef1e5; + --color-net: #3b82f6; + --color-net-soft: #ecf2fe; + /* + * Ağ grafiğinde İKİ çizgi var (gelen / giden) ve giden trafik MAGENTA. + * + * Önce camgöbeği (#06b6d4) denendi: gerekçe "maviyle aynı soğuk aileden, + * ikisi birlikte ağ diye okunur ama tonu ayırt edilecek kadar uzak" idi. + * Gerçek grafikte tutmadı — mavi ile camgöbeği arasında yalnızca ~20° + * ton farkı var ve agent'ın trafiği testere dişi gibi dalgalandığı için + * çizgiler saniyede bir kesişiyor; kullanıcı hangisinin gelen hangisinin + * giden olduğunu ayırt edemedi. + * + * Magenta, paletin KULLANILMAYAN tek geniş ton bölgesi: CPU moru 250°, + * RAM yeşili 160°, disk turuncusu 25°, ağ mavisi 217°. 330° hiçbirine + * komşu değil ve maviden ~113° uzak — üst üste binen iki çizgi bile + * birbirinden ayrılıyor. "Ağ ailesi" fikri kaybedildi ama o fikir zaten + * bir grafiğin İÇİNDE değil, iki grafik YAN YANAYKEN işe yarıyordu; + * grafiğin başlığı ve efsanesi o işi zaten yapıyor. + */ + --color-net-out: #d6248b; + --color-net-out-soft: #fdeaf4; + + --radius-card: 0.75rem; /* tüm kartlarda AYNI yarıçap — referansta 12px */ + + /* + * Kart gölgesi çok hafif. Açık temada kartı zeminden ayıran şey neredeyse + * yalnızca çerçeve; gölge sadece kartonluğu alıyor. Koyu temada gölge + * görünmez (siyah üstüne siyah), aşağıda `none`. + */ + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.04); + --shadow-pop: 0 10px 30px rgb(16 24 40 / 0.12); +} + +/* --- koyu tema (referans 1) ------------------------------------------------ + * Aynı değişkenler, farklı değerler. Ölçü renkleri de tazeleniyor: açık + * temanın yeşili koyu lacivert üstünde donuk kalıyor, parlatılmadan okunmuyor. + * "soft" zeminler burada açık değil KOYU — aynı rolü (rozet zemini) koyu + * temada oynayan şey, ana rengin çok düşük parlaklıkta hâli. + */ +[data-theme="dark"] { + --color-bg: #070b15; + --color-bg-soft: #0a101d; + --color-panel: #0e1424; + --color-panel-2: #161e33; + --color-line: #1f2942; + --color-fg: #e8edf7; --color-muted: #8794ad; - --color-faint: #5a6580; /* eksen etiketi, ızgara yazısı — muted'in altı */ + --color-faint: #5a6580; - /* --- birincil aksiyon: indigo / electric blue (plan §2) ----------------- */ - --color-accent: #4f5dfb; - --color-accent-strong: #3f4ce8; - --color-accent-soft: #1b2450; /* seçili satır zemini, ikon kutusu */ + --color-accent: #7c6cff; + --color-accent-strong: #6b5aff; + --color-accent-soft: #1e2350; - /* --- durum renkleri (plan §2) ------------------------------------------ */ --color-ok: #22c55e; + --color-ok-soft: #0c2c1e; --color-warn: #f59e0b; - --color-danger: #ef4444; - --color-info: #3b82f6; + --color-warn-soft: #33230a; + --color-danger: #f05252; + --color-danger-soft: #351417; + --color-info: #60a5fa; + --color-info-soft: #10203c; + + --color-cpu: #8b7cff; + --color-cpu-soft: #1f1d47; + --color-ram: #34d399; + --color-ram-soft: #0c2c22; + --color-disk: #fb923c; + --color-disk-soft: #33200e; + --color-net: #60a5fa; + --color-net-soft: #10203c; + --color-net-out: #f472b6; + --color-net-out-soft: #3a1024; + + --shadow-card: none; + --shadow-pop: 0 10px 30px rgb(0 0 0 / 0.5); +} + +/* + * color-scheme, tarayıcının KENDİ çizdiği parçaları haberdar ediyor: kaydırma + * çubuğu, form denetimleri, otomatik doldurma zemini. Yalnızca değişkenleri + * değiştirseydik açık temada koyu bir kaydırma çubuğu kalabilirdi. + */ +:root { + color-scheme: light; /* - * --- metrik renkleri --------------------------------------------------- - * Plan §2: primary indigo, secondary violet, "network ve I/O metriklerinde - * cyan". Disk bir I/O ölçüsü, bu yüzden cyan ona düşüyor. - * - * Üç grafik alt alta duruyor ve aynı x eksenini paylaşıyor; renk burada - * dekorasyon değil, hangi bandın hangi ölçüye ait olduğunu söyleyen tek - * işaret. Üçü de aynı renkte olsaydı imleç bir grafikten diğerine geçerken - * kullanıcı yerini kaybederdi. + * Kenar çubuğunun genişliği — görselden ölçüldü (kenar çizgisi x=212). + * Hem çubuğun kendisi hem içerik alanının sol boşluğu bunu okuyor. İki + * yerde ayrı ayrı yazılsaydı biri değiştiğinde içerik ya çubuğun altına + * girer ya arada boşluk bırakırdı. @theme'de değil: bu bir renk ölçeği + * değil, tek bir ölçü — utility üretmesine gerek yok. */ - --color-cpu: #6366f1; - --color-ram: #a78bfa; - --color-disk: #22d3ee; - - --radius-card: 0.875rem; /* tüm kartlarda AYNI yarıçap (plan §15) */ + --sidebar-w: 212px; +} +[data-theme="dark"] { + color-scheme: dark; } html, @@ -59,25 +175,18 @@ body { color: var(--color-fg); } +/* + * Zemin DÜZ. Önceki turda sol üstten inen soluk bir mor hâle vardı; + * referansta yok — ölçülen her noktada zemin aynı #fcfcfe. Gradyan, birebir + * kopyada fark edilen ilk sapmalardan biriydi. + */ body { -webkit-font-smoothing: antialiased; - /* - * Referans görseldeki derinlik: sol üstten inen çok soluk bir indigo hâle. - * Plan §1 "gereksiz gradient, glow ve dekoratif elementleri minimumda tut" - * diyor — bu yüzden tek bir radial, %6 opaklıkta ve sabit. Kaydırmayla - * hareket etmiyor, dikkat çekmiyor; yalnızca düz siyahın kartonluğunu alıyor. - */ - background-image: radial-gradient( - 900px 600px at 12% -10%, - color-mix(in oklab, var(--color-accent) 9%, transparent), - transparent 70% - ); - background-attachment: fixed; } /* - * Kaydırma çubuğu: varsayılan açık gri çubuk koyu panelin üstünde parlayan bir - * çizgi gibi duruyor ve göz doğrudan oraya gidiyordu. + * Kaydırma çubuğu inceltiliyor. Varsayılan kalın çubuk, panel kenarında + * gereğinden fazla yer kaplıyor ve göz doğrudan oraya gidiyor. */ * { scrollbar-width: thin; @@ -85,9 +194,8 @@ body { } /* - * Klavye ile gezinen kullanıcı nerede olduğunu görmeli. Tarayıcının varsayılan - * odak halkası koyu zeminde neredeyse görünmez; accent ile değiştiriliyor. - * :focus-visible kullanılıyor, :focus değil — fareyle tıklayana halka çıkmaz. + * Klavye ile gezinen kullanıcı nerede olduğunu görmeli. :focus-visible + * kullanılıyor, :focus değil — fareyle tıklayana halka çıkmaz. */ :focus-visible { outline: 2px solid var(--color-accent); diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx index aeb9b6b..6e85e84 100644 --- a/dashboard/app/layout.tsx +++ b/dashboard/app/layout.tsx @@ -1,9 +1,10 @@ import type { Metadata } from "next"; import "./globals.css"; +import { THEME_SCRIPT, ThemeProvider } from "@/lib/theme"; export const metadata: Metadata = { title: "TraceBox", - description: "Makine çökmeden önceki anı başka bir cihazdan görebilmek.", + description: "See the moments before a machine went down, from another device.", }; export default function RootLayout({ @@ -12,8 +13,19 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {children} + /* + * data-theme sunucuda "light" yazılıyor, tarayıcıda aşağıdaki script onu + * kullanıcının tercihiyle değiştirebiliyor. React bu farkı bir uyumsuzluk + * sanır; suppressHydrationWarning tam olarak bunun için var — fark + * kasıtlı ve yalnızca bu öznitelikte. + */ + + +