diff --git a/server/history-store.mjs b/server/history-store.mjs index 6982511..9112bbb 100644 --- a/server/history-store.mjs +++ b/server/history-store.mjs @@ -49,6 +49,17 @@ CREATE INDEX IF NOT EXISTS idx_idare ON sonuclar(idare_adi); CREATE INDEX IF NOT EXISTS idx_il ON sonuclar(il); CREATE INDEX IF NOT EXISTS idx_tarih ON sonuclar(ihale_tarihi); +CREATE TABLE IF NOT EXISTS havuz_olcum ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zaman TEXT NOT NULL, + kaynak_toplam INTEGER, -- ilan.gov.tr'nin bildirdiği numFound + yuklenen INTEGER, -- bizim havuzumuza giren kayıt + eksik INTEGER, -- kaynak_toplam - yuklenen + dt INTEGER, -- doğrudan temin kaydı + arsiv INTEGER -- geçmiş sonuç arşivi +); +CREATE INDEX IF NOT EXISTS idx_havuz_zaman ON havuz_olcum(zaman); + CREATE TABLE IF NOT EXISTS tarama_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, baslangic TEXT, bitis TEXT, @@ -224,3 +235,75 @@ export function logScan(db, row) { VALUES (?,?,?,?,?,?,?,?)`) .run(row.baslangic, row.bitis, row.aralikBas, row.aralikSon, row.taranan, row.eklenen, row.hata, row.not || null); } + + +// --- Havuz izleme ---------------------------------------------------------- +// Havuz büyüklüğü kaynağa (ilan.gov.tr) bağlı ve gün içinde ciddi dalgalanıyor. +// Ölçümleri saklarız ki "veri mi kayboldu, kaynak mı düştü" sorusu bir daha +// tahmine kalmasın. + +/** Bir ölçüm kaydeder ve önceki ölçüme göre ani düşüş olup olmadığını döndürür. */ +export function havuzOlc({ kaynakToplam, yuklenen, dt = null, arsiv = null }) { + const db = openDb({ create: true }); + if (!db) return null; + try { + const onceki = db + .prepare('SELECT zaman, kaynak_toplam, yuklenen FROM havuz_olcum ORDER BY id DESC LIMIT 1') + .get() || null; + + db.prepare( + `INSERT INTO havuz_olcum (zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv) + VALUES (?,?,?,?,?,?)` + ).run( + new Date().toISOString(), + kaynakToplam ?? null, + yuklenen ?? null, + kaynakToplam != null && yuklenen != null ? kaynakToplam - yuklenen : null, + dt, arsiv + ); + + // 90 günden eski ölçümleri at — bu tablo sınırsız büyümemeli. + const sinir = new Date(Date.now() - 90 * 864e5).toISOString(); + db.prepare('DELETE FROM havuz_olcum WHERE zaman < ?').run(sinir); + + return degerlendir(onceki, { kaynakToplam, yuklenen }); + } finally { + db.close(); + } +} + +/** Ölçümü önceki ölçümle karşılaştırır; uyarı gerekiyorsa açıklamasını döndürür. */ +function degerlendir(onceki, { kaynakToplam, yuklenen }) { + const sonuc = { uyari: null, onceki: onceki?.kaynak_toplam ?? null, simdi: kaynakToplam ?? null }; + + // 1) Kaynak düştü mü? (bizim hatamız değil ama bilmek isteriz) + if (onceki?.kaynak_toplam > 0 && kaynakToplam != null) { + const oran = kaynakToplam / onceki.kaynak_toplam; + if (oran < 0.5) { + sonuc.uyari = `Kaynak havuzu %${Math.round((1 - oran) * 100)} düştü: ` + + `${onceki.kaynak_toplam} → ${kaynakToplam} ilan (ilan.gov.tr tarafı).`; + sonuc.tur = 'kaynak_dusus'; + } + } + + // 2) Biz kaynağın gerisinde mi kaldık? (bu BİZİM hatamız olur) + if (!sonuc.uyari && kaynakToplam > 0 && yuklenen != null && yuklenen < kaynakToplam * 0.9) { + sonuc.uyari = `Havuz eksik yüklendi: kaynakta ${kaynakToplam}, bizde ${yuklenen} ilan.`; + sonuc.tur = 'eksik_yukleme'; + } + + return sonuc; +} + +/** Son ölçümler — /api/health/havuz ve arayüz için. */ +export function havuzGecmisi(limit = 60) { + const db = openDb(); + if (!db) return []; + try { + return db + .prepare('SELECT zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv FROM havuz_olcum ORDER BY id DESC LIMIT ?') + .all(Math.min(Number(limit) || 60, 500)); + } finally { + db.close(); + } +} diff --git a/server/index.js b/server/index.js index 721f3af..58e4b9f 100644 --- a/server/index.js +++ b/server/index.js @@ -24,7 +24,7 @@ import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSessio import * as store from './user-store.mjs'; import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs'; import { searchTed } from './ted.mjs'; -import { readHistory, historyStats } from './history-store.mjs'; +import { readHistory, historyStats, havuzOlc, havuzGecmisi } from './history-store.mjs'; import { startScheduler, schedulerStatus } from './scheduler.mjs'; import { searchKik, kararMetni } from './kik.mjs'; import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs'; @@ -324,6 +324,7 @@ async function arkaPlandaTamamla(numFound, ilkListe) { cache.todayCount = yeni.filter((t) => t.isYeni).length; } console.log(`[ihaleist-api] havuz hazır: ${cache.tenders.length}/${numFound} ilan`); + havuzKaydet(numFound, cache.tenders.length); } catch (e) { console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message); } finally { @@ -331,6 +332,40 @@ async function arkaPlandaTamamla(numFound, ilkListe) { } } +// Havuz büyüklüğünü kaydeder; ani düşüş / eksik yükleme varsa yöneticiyi uyarır. +let sonHavuzUyarisi = 0; +function dtSayisiGuvenli() { + try { return readDtTenders(1)?.totalCount ?? null; } catch { return null; } +} +function havuzKaydet(kaynakToplam, yuklenen) { + try { + const ist = historyStats?.() || {}; + const sonuc = havuzOlc({ + kaynakToplam, + yuklenen, + dt: dtSayisiGuvenli(), + arsiv: ist.toplam ?? null, + }); + if (!sonuc?.uyari) return; + console.warn(`[havuz-izleme] ${sonuc.uyari}`); + // Aynı uyarıyı sürekli göndermemek için 6 saatte bir e-posta. + if (Date.now() - sonHavuzUyarisi < 6 * 3600e3) return; + sonHavuzUyarisi = Date.now(); + const alicilar = adminEmails(); + if (!alicilar.length || !emailConfigured()) return; + sendEmail({ + to: alicilar, + subject: sonuc.tur === 'eksik_yukleme' + ? 'İhaleist: havuz eksik yüklendi' + : 'İhaleist: canlı ihale havuzu ani düştü', + text: `${sonuc.uyari}\n\nÖnceki ölçüm: ${sonuc.onceki ?? '-'}\nŞimdi: ${sonuc.simdi ?? '-'}\n\n` + + `Ayrıntı: ${process.env.APP_URL || 'https://ihaleist.seymata.com'}/api/health/havuz`, + }).catch((e) => console.warn('[havuz-izleme] uyarı e-postası gönderilemedi:', e.message)); + } catch (e) { + console.warn('[havuz-izleme] ölçüm alınamadı:', e.message); + } +} + async function getTenders(force = false) { const fresh = cache.tenders && Date.now() - cache.fetchedAt < CACHE_TTL_MS; if (fresh && !force) return cache; @@ -930,6 +965,23 @@ const server = http.createServer(async (req, res) => { } if (url.pathname === '/api/health') return json(res, 200, { ok: true }); + if (url.pathname === '/api/health/havuz') { + const gecmis = havuzGecmisi(Number(url.searchParams.get('limit')) || 60); + const son = gecmis[0] || null; + // Son 7 günün en yükseği — bugünkü sayı buna göre kıyaslanır. + const haftaBas = new Date(Date.now() - 7 * 864e5).toISOString(); + const hafta = gecmis.filter((r) => r.zaman >= haftaBas); + const zirve = hafta.reduce((m, r) => Math.max(m, r.kaynak_toplam || 0), 0); + return json(res, 200, { + success: true, + son, + haftalikZirve: zirve || null, + zirveyeOran: son?.kaynak_toplam && zirve ? Number((son.kaynak_toplam / zirve).toFixed(3)) : null, + olcumSayisi: gecmis.length, + gecmis, + }); + } + if (url.pathname === '/api/health/scheduler') { return json(res, 200, { success: true, tarayiciVar: await browserAvailable(), ...schedulerStatus() }); } diff --git a/src/components/LiveStatsBar.jsx b/src/components/LiveStatsBar.jsx index 13a50ea..271e6fb 100644 --- a/src/components/LiveStatsBar.jsx +++ b/src/components/LiveStatsBar.jsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useApp } from '../context/AppContext'; -import { Database, PlusCircle, Eye, Layers, Trophy } from 'lucide-react'; +import { Database, PlusCircle, Eye, Layers, Trophy, TrendingDown } from 'lucide-react'; export const LiveStatsBar = () => { const { liveStats, tenders } = useApp(); @@ -9,6 +9,17 @@ export const LiveStatsBar = () => { const takibimdeki = tenders.filter((t) => t.takipte).length; const kalemSayisi = tenders.reduce((s, t) => s + (t.items?.length || 0), 0); + // Havuz sağlığı: kaynak (ilan.gov.tr) sayısı haftalık zirvenin çok altındaysa + // bunu açıkça söyleriz — kullanıcı "veri mi kayboldu?" diye düşünmesin. + const [havuz, setHavuz] = useState(null); + useEffect(() => { + fetch('/api/health/havuz?limit=40') + .then((r) => r.json()) + .then((d) => setHavuz(d.success ? d : null)) + .catch(() => {}); + }, []); + const dusuk = havuz?.zirveyeOran != null && havuz.zirveyeOran < 0.6; + const stats = [ { deger: liveStats.toplamIhale, etiket: 'Canlı İhale Havuzu', renk: 'text-white', dot: true }, { deger: yukluIhale, etiket: 'Yüklü İhale', renk: 'text-white', icon: Database, iconRenk: 'text-blue-400' }, @@ -35,6 +46,18 @@ export const LiveStatsBar = () => { ); })} + + {dusuk && ( +