feat: havuz izleme — ölçüm geçmişi, ani düşüş/eksik yükleme uyarısı, /api/health/havuz
This commit is contained in:
@@ -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_il ON sonuclar(il);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tarih ON sonuclar(ihale_tarihi);
|
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 (
|
CREATE TABLE IF NOT EXISTS tarama_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
baslangic TEXT, bitis TEXT,
|
baslangic TEXT, bitis TEXT,
|
||||||
@@ -224,3 +235,75 @@ export function logScan(db, row) {
|
|||||||
VALUES (?,?,?,?,?,?,?,?)`)
|
VALUES (?,?,?,?,?,?,?,?)`)
|
||||||
.run(row.baslangic, row.bitis, row.aralikBas, row.aralikSon, row.taranan, row.eklenen, row.hata, row.not || null);
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+53
-1
@@ -24,7 +24,7 @@ import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSessio
|
|||||||
import * as store from './user-store.mjs';
|
import * as store from './user-store.mjs';
|
||||||
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
||||||
import { searchTed } from './ted.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 { startScheduler, schedulerStatus } from './scheduler.mjs';
|
||||||
import { searchKik, kararMetni } from './kik.mjs';
|
import { searchKik, kararMetni } from './kik.mjs';
|
||||||
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.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;
|
cache.todayCount = yeni.filter((t) => t.isYeni).length;
|
||||||
}
|
}
|
||||||
console.log(`[ihaleist-api] havuz hazır: ${cache.tenders.length}/${numFound} ilan`);
|
console.log(`[ihaleist-api] havuz hazır: ${cache.tenders.length}/${numFound} ilan`);
|
||||||
|
havuzKaydet(numFound, cache.tenders.length);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message);
|
console.warn('[ihaleist-api] arka plan doldurma hatası:', e.message);
|
||||||
} finally {
|
} 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) {
|
async function getTenders(force = false) {
|
||||||
const fresh = cache.tenders && Date.now() - cache.fetchedAt < CACHE_TTL_MS;
|
const fresh = cache.tenders && Date.now() - cache.fetchedAt < CACHE_TTL_MS;
|
||||||
if (fresh && !force) return cache;
|
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') 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') {
|
if (url.pathname === '/api/health/scheduler') {
|
||||||
return json(res, 200, { success: true, tarayiciVar: await browserAvailable(), ...schedulerStatus() });
|
return json(res, 200, { success: true, tarayiciVar: await browserAvailable(), ...schedulerStatus() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext';
|
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 = () => {
|
export const LiveStatsBar = () => {
|
||||||
const { liveStats, tenders } = useApp();
|
const { liveStats, tenders } = useApp();
|
||||||
@@ -9,6 +9,17 @@ export const LiveStatsBar = () => {
|
|||||||
const takibimdeki = tenders.filter((t) => t.takipte).length;
|
const takibimdeki = tenders.filter((t) => t.takipte).length;
|
||||||
const kalemSayisi = tenders.reduce((s, t) => s + (t.items?.length || 0), 0);
|
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 = [
|
const stats = [
|
||||||
{ deger: liveStats.toplamIhale, etiket: 'Canlı İhale Havuzu', renk: 'text-white', dot: true },
|
{ 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' },
|
{ deger: yukluIhale, etiket: 'Yüklü İhale', renk: 'text-white', icon: Database, iconRenk: 'text-blue-400' },
|
||||||
@@ -35,6 +46,18 @@ export const LiveStatsBar = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{dusuk && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 text-amber-300"
|
||||||
|
title={`ilan.gov.tr'de şu an ${havuz.son?.kaynak_toplam?.toLocaleString('tr-TR')} açık ihale var; bu hafta en yüksek ${havuz.haftalikZirve?.toLocaleString('tr-TR')} idi. Havuzun tamamı yüklendi — eksik veri yok.`}
|
||||||
|
>
|
||||||
|
<TrendingDown className="w-3.5 h-3.5" />
|
||||||
|
<span className="font-bold">
|
||||||
|
Kaynakta ihale sayısı düşük (%{Math.round(havuz.zirveyeOran * 100)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user