diff --git a/Makefile b/Makefile index 50889fa..4602698 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install run test fuzz lint format audit a11y ci migrate schema-dump backup restore docker-up docker-down clean +.PHONY: help install run demo-readings test fuzz lint format audit a11y ci migrate schema-dump backup restore docker-up docker-down clean help: ## Show this help @egrep -h '\s##\s' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' @@ -10,6 +10,9 @@ install: ## Install python dependencies run: ## Run the FastAPI application locally uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 +demo-readings: ## Demo öncesi: sensörlere son 48h taze toprak nemi okuması (DB sıfırlamadan) + PYTHONPATH=. python scripts/seed_demo_readings.py + test: ## Run the test suite with coverage (Schemathesis fuzz hariç — `make fuzz`) SKIP_SCHEMATHESIS=1 pytest tests/ --cov=app --cov-report=term-missing diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 6dcc2c4..dafec75 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -44,6 +44,7 @@ from app.schemas.schemas import ( DashboardLastDisease, DashboardLastIrrigation, + DashboardMoisturePoint, DashboardOpenAlerts, DashboardSoilMoisture, DashboardSummaryResponse, @@ -145,6 +146,28 @@ def get_dashboard_summary( status=_classify_moisture(avg_moisture), ) + # Toprak nemi trendi (son 24 saat, saatlik ortalama) — dashboard grafiği için. + # DB-agnostik: ham okumalar Python'da saatlik bucket'lanır (~192 satır, hafif). + trend_q = ( + db.query(SoilMoistureReading.reading_timestamp, SoilMoistureReading.moisture_percent) + .join(Sensor, SoilMoistureReading.sensor_id == Sensor.id) + .join(Field, Sensor.field_id == Field.id) + .join(Farm, Field.farm_id == Farm.id) + .filter(SoilMoistureReading.reading_timestamp >= since) + ) + if not is_system: + trend_q = trend_q.filter(Farm.user_id == current_user.id) + hourly_buckets: dict[datetime, list[float]] = {} + for ts, moisture in trend_q.all(): + if ts is None or moisture is None: + continue + bucket = ts.replace(minute=0, second=0, microsecond=0) + hourly_buckets.setdefault(bucket, []).append(moisture) + moisture_trend = [ + DashboardMoisturePoint(hour=bucket, moisture_percent=round(sum(vals) / len(vals), 1)) + for bucket, vals in sorted(hourly_buckets.items()) + ] + # ─── 2. Son sulama ─────────────────────────────────────────── irrigation_q = ( db.query(IrrigationSchedule, Field.name) @@ -224,6 +247,7 @@ def get_dashboard_summary( field_count=field_count, sensor_count=sensor_count, soil_moisture_today=soil_moisture, + soil_moisture_trend=moisture_trend, last_irrigation=last_irrigation, open_alerts=open_alerts, last_disease=last_disease, diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py index 55b10d7..daa921d 100644 --- a/app/schemas/dashboard.py +++ b/app/schemas/dashboard.py @@ -22,6 +22,13 @@ class DashboardSoilMoisture(BaseModel): status: str = "no_data" # 'dry' | 'optimal' | 'wet' | 'no_data' +class DashboardMoisturePoint(BaseModel): + """Saatlik ortalama toprak nemi — dashboard 'Toprak Nemi Trendi' grafiği için.""" + + hour: UtcDateTime + moisture_percent: float + + class DashboardLastIrrigation(BaseModel): """En son planlanan/gerçekleşen sulama kaydı.""" @@ -69,6 +76,7 @@ class DashboardSummaryResponse(BaseModel): field_count: int sensor_count: int soil_moisture_today: DashboardSoilMoisture + soil_moisture_trend: list[DashboardMoisturePoint] = Field(default_factory=list) last_irrigation: DashboardLastIrrigation open_alerts: DashboardOpenAlerts last_disease: DashboardLastDisease diff --git a/docs/demo_script.md b/docs/demo_script.md index d10259f..ad121bf 100644 --- a/docs/demo_script.md +++ b/docs/demo_script.md @@ -18,6 +18,8 @@ rm -f sfdap_dev.db && python database/seed_data.py make run # uvicorn → http://localhost:8000 ``` +> 💡 **Alternatif** (DB sıfırlamadan): mevcut demo verisini koruyup okumaları tazelemek → `make demo-readings`. + Tarayıcıda aç: **http://localhost:8000/dashboard/** (+ opsiyonel `/docs` Swagger). **Demo hesapları:** diff --git a/frontend/index.html b/frontend/index.html index 5b9a183..62d5f3e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -899,7 +899,7 @@

Dashboard

-

🌾 Hoş geldin, çiftçi!

+

🌾 Hoş geldin, çiftçi!

Çiftliğinden toplanan toprak, hava ve sulama bilgilerini tek bir ekranda gör. Akıllı asistan seninle çalışıyor.

🚜 çiftlik
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 31d1689..31b72c9 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -104,6 +104,7 @@ export async function apiAuth(endpoint, options = {}) { return await res.json(); } catch (e) { console.warn(`API Auth Error: ${endpoint}`, e); + if (e instanceof TypeError) _showToast?.('Sunucuya ulaşılamadı', 'error'); return null; } } diff --git a/frontend/src/lib/charts.js b/frontend/src/lib/charts.js index 7d19aac..475a0d5 100644 --- a/frontend/src/lib/charts.js +++ b/frontend/src/lib/charts.js @@ -51,12 +51,12 @@ export function rethemeCharts() { } export function renderMoistureChart(data) { - const sorted = [...data].reverse(); - const labels = sorted.map(d => new Date(d.recorded_at).toLocaleDateString('tr')); - const values = sorted.map(d => d.humidity_percent); + const points = Array.isArray(data) ? data : []; + const labels = points.map(d => new Date(d.hour).toLocaleString('tr', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })); + const values = points.map(d => d.moisture_percent); if (charts.moisture) charts.moisture.destroy(); charts.moisture = new Chart(document.getElementById('moistureChart'), { - type: 'line', data: { labels, datasets: [{ label: 'Nem %', data: values, borderColor: '#3b82f6', + type: 'line', data: { labels, datasets: [{ label: 'Toprak Nemi %', data: values, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,.1)', fill: true, tension: .4, pointRadius: 2 }] }, options: { responsive: true, plugins: { legend: { labels: { color: chartLegend() } } }, scales: { x: { ticks: { color: chartTick() }, grid: { color: chartGrid() } }, y: { ticks: { color: chartTick() }, grid: { color: chartGrid() } } } } @@ -246,7 +246,7 @@ export function renderDailyTrendChart(trends) { } export function renderSensorStatsChart(stats) { - if (!stats || !stats.moisture.avg) return; + if (!stats || !stats.moisture || !stats.soil_temperature || stats.moisture.avg == null) return; if (charts.sensorStats) charts.sensorStats.destroy(); charts.sensorStats = new Chart(document.getElementById('sensorStatsChart'), { type: 'bar', diff --git a/frontend/src/lib/filiz.js b/frontend/src/lib/filiz.js index 6ff8adf..1fa5c2f 100644 --- a/frontend/src/lib/filiz.js +++ b/frontend/src/lib/filiz.js @@ -423,7 +423,7 @@ export function initFiliz() { setFilizMood(isLateNight ? 'sleepy' : 'happy'); // Alert listesini balon için arka planda topla (mood'u etkilemez) - if (getApiOnline()) { + if (getApiOnline() && getCurrentUser()) { try { const alerts = await api('/api/alerts/?severity=critical&is_resolved=false&limit=10'); filizCriticalAlerts = alerts || []; diff --git a/frontend/src/lib/labels.js b/frontend/src/lib/labels.js new file mode 100644 index 0000000..c638207 --- /dev/null +++ b/frontend/src/lib/labels.js @@ -0,0 +1,26 @@ +/* ════════════════════════════════════════════════════════════════ + i18n — backend enum değerlerini Türkçe etikete çevirir. + Bilinmeyen değer → değerin kendisi (fallback); null/boş → '—'. + ════════════════════════════════════════════════════════════════ */ +const _map = (m) => (v) => (v == null || v === '' ? '—' : (m[String(v).toLowerCase()] ?? String(v))); + +// Uyarı + hastalık şiddeti (low/medium/critical + none/low/medium/high) +export const severityLabel = _map({ + none: 'Yok', low: 'Düşük', medium: 'Orta', high: 'Yüksek', critical: 'Kritik', +}); + +// Uyarı tipi +export const alertTypeLabel = _map({ + low_moisture: 'Düşük nem', disease_reminder: 'Hastalık hatırlatması', model_drift: 'Model sapması', +}); + +// Sulama durumu +export const irrigationStatusLabel = _map({ + pending: 'Beklemede', completed: 'Tamamlandı', cancelled: 'İptal', scheduled: 'Planlandı', +}); + +// Hastalık tanısı (ML sınıfları) +export const diagnosisLabel = _map({ + healthy: 'Sağlıklı', leaf_spot: 'Yaprak lekesi', powdery_mildew: 'Külleme', rust: 'Pas', + blight: 'Yanıklık', mosaic_virus: 'Mozaik virüsü', bacterial_wilt: 'Bakteriyel solgunluk', anthracnose: 'Antraknoz', +}); diff --git a/frontend/src/lib/pages/alerts.js b/frontend/src/lib/pages/alerts.js index 32fb463..713b104 100644 --- a/frontend/src/lib/pages/alerts.js +++ b/frontend/src/lib/pages/alerts.js @@ -7,8 +7,9 @@ ============================================================ */ import { _escAttr, showToast } from "../utils.js"; -import { api, apiAuth, API_BASE } from "../api.js"; +import { api, apiAuth } from "../api.js"; import { _skeletonCards, _skeletonBlock, _setBusy } from "../skeleton.js"; +import { severityLabel, alertTypeLabel } from "../labels.js"; // ─── ALERTS ─────────────────────────────────────────────────── export async function loadAlerts() { @@ -57,9 +58,9 @@ export async function loadAlerts() { : ``; html += ` ${date} - ${a.severity} - ${a.alert_type} - ${a.message} + ${severityLabel(a.severity)} + ${alertTypeLabel(a.alert_type)} + ${_escAttr(a.message)} ${status} `; } @@ -69,20 +70,10 @@ export async function loadAlerts() { } export async function resolveAlert(id) { - try { - const resp = await fetch(`${API_BASE}/api/alerts/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', 'X-API-Key': 'dev-api-key' }, - body: JSON.stringify({ is_resolved: true }), - }); - if (resp.ok) { - showToast('Uyarı çözüldü olarak işaretlendi', 'success'); - loadAlerts(); - } else { - showToast(`Hata ${resp.status}`, 'error'); - } - } catch (e) { - showToast('Sunucuya ulaşılamadı', 'error'); + const res = await apiAuth(`/api/alerts/${id}`, { method: 'PATCH', body: JSON.stringify({ is_resolved: true }) }); + if (res) { + showToast('Uyarı çözüldü olarak işaretlendi', 'success'); + loadAlerts(); } } @@ -115,7 +106,7 @@ export async function refreshBell() {
${_escAttr(a.message)}
- ${_escAttr(a.severity)} + ${_escAttr(severityLabel(a.severity))}
`).join(''); diff --git a/frontend/src/lib/pages/dashboard.js b/frontend/src/lib/pages/dashboard.js index e9e7680..2aad7f3 100644 --- a/frontend/src/lib/pages/dashboard.js +++ b/frontend/src/lib/pages/dashboard.js @@ -9,6 +9,7 @@ import { _fmtDate, _fmtNumber, _escAttr, showToast, updateStatus, _STATUS_EMOJI, _STATUS_LABEL } from "../utils.js"; import { api, apiAuth, getAuthToken } from "../api.js"; import { _skeletonCards, _setBusy } from "../skeleton.js"; +import { irrigationStatusLabel, diagnosisLabel } from "../labels.js"; import { renderMoistureChart, renderTempHumChart, renderPrecipChart } from "../charts.js"; import { getCurrentUser, getApiOnline, setApiOnline } from "../session.js"; import { _getRoleTips } from "../filiz.js"; @@ -41,7 +42,7 @@ function _renderSummaryCards(summary) { ? `${cnt} ${label}` : ''; }).join(' '); - const diseaseDx = disease.diagnosis || '—'; + const diseaseDx = diagnosisLabel(disease.diagnosis); const diseaseDetail = disease.diagnosis ? `${disease.field_name || 'Tarla'} · ${_fmtDate(disease.captured_at)} · güven %${_fmtNumber((disease.confidence_score || 0) * 100, 0)}` : 'Henüz yaprak görseli analiz edilmedi'; @@ -66,7 +67,7 @@ function _renderSummaryCards(summary) {
${irrAmount}
${_escAttr(irr.field_name || '—')} · ${_fmtDate(irr.scheduled_date)}
-
${_escAttr(irr.status || (irr.irrigation_id ? '—' : 'Sulama kaydı yok'))}
+
${_escAttr(irr.status ? irrigationStatusLabel(irr.status) : (irr.irrigation_id ? '—' : 'Sulama kaydı yok'))}
${scopeNote}
@@ -146,6 +147,10 @@ export async function loadDashboard() { ]); setApiOnline(summary !== null); updateStatus(getApiOnline()); + if (summary) { + const heroGreet = document.getElementById('heroGreetName'); + if (heroGreet) heroGreet.textContent = summary.user_name || 'çiftçi'; + } if (summary && summary.scope === 'user' && summary.farm_count === 0) { // Boş hesap — onboarding banner (REBUILD Faz 6) @@ -173,7 +178,7 @@ export async function loadDashboard() { // Trend chart'lar — weather Bearer üzerinden gelir (api() artık _authHeaders kullanıyor). const w = weather || []; - renderMoistureChart(w); + renderMoistureChart((summary && summary.soil_moisture_trend) || []); renderTempHumChart(w); renderPrecipChart(w); } diff --git a/frontend/src/lib/pages/fields.js b/frontend/src/lib/pages/fields.js index 3a30d9e..c037a03 100644 --- a/frontend/src/lib/pages/fields.js +++ b/frontend/src/lib/pages/fields.js @@ -9,6 +9,7 @@ import { _fmtNumber, _escAttr, showToast } from "../utils.js"; import { apiAuth, getAuthToken, API_BASE } from "../api.js"; import { _skeletonBlock, _setBusy } from "../skeleton.js"; +import { diagnosisLabel, severityLabel } from "../labels.js"; import { charts, chartTick, chartLegend, chartGrid } from "../charts.js"; import { renderFieldDetail } from "../render.js"; import { pageTitles } from "../router.js"; @@ -141,7 +142,7 @@ export async function submitNewFarm() { area_hectares: parseFloat(document.getElementById('nfArea').value) || null, }; const res = await apiAuth('/api/farms/', { method: 'POST', body: JSON.stringify(body) }); - if (res) { showToast('Çiftlik eklendi ✅', 'success'); loadFields(); } + if (res) { ['nfName', 'nfCity', 'nfRegion', 'nfArea'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); showToast('Çiftlik eklendi ✅', 'success'); loadFields(); } } export async function submitNewField() { @@ -155,7 +156,7 @@ export async function submitNewField() { area_hectares: parseFloat(document.getElementById('ndArea').value) || null, }; const res = await apiAuth('/api/fields', { method: 'POST', body: JSON.stringify(body) }); - if (res) { showToast('Tarla eklendi ✅', 'success'); loadFields(); } + if (res) { ['ndName', 'ndSoil', 'ndArea'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); showToast('Tarla eklendi ✅', 'success'); loadFields(); } } export async function editFarm(farmId, currentName) { @@ -211,6 +212,7 @@ export async function submitNewSensor(fieldId) { }; const res = await apiAuth('/api/sensors/', { method: 'POST', body: JSON.stringify(body) }); if (res) { + ['nsSerial', 'nsDepth'].forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; }); showToast('Sensör eklendi ✅', 'success'); if (currentFieldId) loadFieldDetail(currentFieldId); } @@ -321,11 +323,11 @@ export async function analyzeFieldLeaf() { } const data = await resp.json(); const sev = data.severity || 'none'; - const sevColor = sev === 'severe' ? '#ef4444' : sev === 'moderate' ? '#f97316' : sev === 'mild' ? '#eab308' : '#22c55e'; + const sevColor = sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f97316' : sev === 'low' ? '#eab308' : '#22c55e'; const box = document.getElementById('fieldLeafResult'); box.innerHTML = `
-

🧪 ${_escAttr(data.diagnosis)}

-

Güven: %${_fmtNumber(data.confidence_score * 100, 0)} · Şiddet: ${_escAttr(sev)}

+

🧪 ${_escAttr(diagnosisLabel(data.diagnosis))}

+

Güven: ${data.confidence_score != null ? '%' + _fmtNumber(data.confidence_score * 100, 0) : '—'} · Şiddet: ${_escAttr(severityLabel(sev))}

`; box.style.display = 'block'; showToast('Analiz tamamlandı ✅', 'success'); diff --git a/frontend/src/lib/pages/monitoring.js b/frontend/src/lib/pages/monitoring.js index c6b6d81..859ade0 100644 --- a/frontend/src/lib/pages/monitoring.js +++ b/frontend/src/lib/pages/monitoring.js @@ -9,6 +9,7 @@ import { _escAttr, showToast } from "../utils.js"; import { api, apiAuth, getAuthToken, API_BASE, _authHeaders } from "../api.js"; import { _skeletonCards, _skeletonRows, _skeletonBlock, _setBusy } from "../skeleton.js"; +import { irrigationStatusLabel, diagnosisLabel, severityLabel } from "../labels.js"; import { charts, renderSensorTypeChart, renderFarmTempChart, renderIrrigationStatusChart, renderNpkRadarChart, renderDailyTrendChart, renderSensorStatsChart, chartTick, chartLegend, chartGrid } from "../charts.js"; @@ -59,7 +60,7 @@ export async function loadSensors(page = 1) { document.getElementById('sensorsTable').innerHTML = sensors.map(s => ` - ${s.id}${s.sensor_type}${s.serial_number} + ${s.id}${s.sensor_type}${_escAttr(s.serial_number)} ${s.status} `).join(''); @@ -91,11 +92,11 @@ export async function loadWeather() { const sorted = [...data].reverse(); // Cards if (data.length) { - const temps = data.map(d => d.temperature_c).filter(Boolean); + const temps = data.map(d => d.temperature_c).filter(t => t != null); document.getElementById('weatherCards').innerHTML = `
${data[0].temperature_c?.toFixed(1) || '—'}°C
Güncel Sıcaklık
%${data[0].humidity_percent?.toFixed(1) || '—'}
Güncel Nem
-
${Math.min(...temps).toFixed(1)}—${Math.max(...temps).toFixed(1)}°C
Sıcaklık Aralığı
+
${temps.length ? Math.min(...temps).toFixed(1) + '—' + Math.max(...temps).toFixed(1) : '—'}°C
Sıcaklık Aralığı
${data.reduce((a,d) => a + (d.precipitation_mm||0), 0).toFixed(1)}mm
Toplam Yağış
`; } @@ -155,7 +156,7 @@ export async function loadIrrigation(page = 1) { Tarla #${s.field_id}${new Date(s.scheduled_date).toLocaleDateString('tr')} ${s.duration_min || '—'} dk${s.water_amount_liters?.toFixed(0) || '—'} - ${s.status} + ${irrigationStatusLabel(s.status)} `).join(''); _setBusy('irrigationTable', false); @@ -216,14 +217,15 @@ export async function predictIrrigation() { export async function approveIrrigation() { const sel = document.getElementById('irrApproveField'); if (!sel || !sel.value) { showToast('Lütfen bir tarla seç', 'warning'); return; } + if (_lastIrrigationRec == null) { showToast('Önce sulama önerisi hesapla', 'warning'); return; } const body = { field_id: parseInt(sel.value, 10), scheduled_date: new Date().toISOString(), - water_amount_liters: _lastIrrigationRec ?? 0, + water_amount_liters: _lastIrrigationRec, }; const res = await apiAuth('/api/irrigation/schedules', { method: 'POST', body: JSON.stringify(body) }); if (res) { - showToast('Sulama programa eklendi ✅ (durum: pending)', 'success'); + showToast(`Sulama programa eklendi ✅ (durum: ${irrigationStatusLabel('pending')})`, 'success'); irrigationTotal = 0; // sayacı tazele loadIrrigation(1); } @@ -235,7 +237,7 @@ export async function updateIrrigationStatus(scheduleId, status) { method: 'PATCH', body: JSON.stringify({ status }), }); if (res) { - showToast(`Sulama durumu: ${status}`, 'success'); + showToast(`Sulama durumu: ${irrigationStatusLabel(status)}`, 'success'); const currentFieldId = getCurrentFieldId(); if (currentFieldId) loadFieldDetail(currentFieldId); } @@ -271,9 +273,9 @@ export async function recommendFertilizer() {

🌱 ${r.crop_name_tr} — Gübreleme Önerisi

-
Azot (N)
${r.nitrogen_needed_kg} kg
-
Fosfor (P)
${r.phosphorus_needed_kg} kg
-
Potasyum (K)
${r.potassium_needed_kg} kg
+
Azot (N)
${r.nitrogen_needed_kg ?? '—'} kg
+
Fosfor (P)
${r.phosphorus_needed_kg ?? '—'} kg
+
Potasyum (K)
${r.potassium_needed_kg ?? '—'} kg

${r.recommendation}

`; @@ -299,6 +301,9 @@ export async function fertilizerSchedule() { ${schedule.map(s => `${s.phase}${s.target_date}${s.fertilizer_type}${s.amount_kg_per_hectare}${s.notes}`).join('')} `; showToast('Gübreleme takvimi oluşturuldu', 'success'); + } else { + document.getElementById('scheduleResult').innerHTML = '

Gübreleme takvimi oluşturulamadı.

'; + showToast('Gübreleme takvimi alınamadı', 'error'); } } @@ -368,9 +373,9 @@ export async function loadPlants() { html += ` ${date} #${r.field_id} - ${r.diagnosis || '—'} + ${diagnosisLabel(r.diagnosis)} ${conf} - ${sev} + ${severityLabel(sev)} `; } html += ''; @@ -391,6 +396,7 @@ export async function analyzePlantImage() { return; } const btn = document.getElementById('plantsAnalyzeBtn'); + if (btn.disabled) return; // çift-submit guard btn.disabled = true; btn.textContent = '⏳ Analiz ediliyor...'; const fd = new FormData(); diff --git a/frontend/src/lib/render.js b/frontend/src/lib/render.js index 9672126..e2c2677 100644 --- a/frontend/src/lib/render.js +++ b/frontend/src/lib/render.js @@ -8,6 +8,7 @@ */ import { _escAttr, _fmtDate, _fmtNumber, _STATUS_EMOJI, _STATUS_LABEL } from "./utils.js"; +import { irrigationStatusLabel, diagnosisLabel, severityLabel } from "./labels.js"; export function renderFieldDetail(d) { const cropName = d.crop ? d.crop.name : 'Ekili bitki yok'; @@ -34,13 +35,13 @@ export function renderFieldDetail(d) { ? ` ` : '—'; - return `${_fmtDate(i.scheduled_date)}${amt}${i.duration_min ?? '—'} dk${_escAttr(i.status)}${actions}`; + return `${_fmtDate(i.scheduled_date)}${amt}${i.duration_min ?? '—'} dk${_escAttr(irrigationStatusLabel(i.status))}${actions}`; }).join('') || 'Sulama kaydı yok.'; // Hastalık geçmişi const disRows = (d.disease_history || []).map(h => { const conf = h.confidence_score != null ? `%${_fmtNumber(h.confidence_score * 100, 0)}` : '—'; - return `${_fmtDate(h.captured_at)}${_escAttr(h.diagnosis || '—')}${conf}${_escAttr(h.severity || '—')}`; + return `${_fmtDate(h.captured_at)}${_escAttr(diagnosisLabel(h.diagnosis))}${conf}${_escAttr(severityLabel(h.severity))}`; }).join('') || 'Hastalık analizi yok.'; // Toprak analizi (en yeni) @@ -53,7 +54,7 @@ export function renderFieldDetail(d) { // Açık uyarılar const alertRows = (d.open_alerts || []).map(a => - `
${_escAttr(a.severity)} · ${_escAttr(a.message)} ${_fmtDate(a.created_at)}
` + `
${_escAttr(severityLabel(a.severity))} · ${_escAttr(a.message)} ${_fmtDate(a.created_at)}
` ).join('') || '

Açık uyarı yok ✅

'; return ` @@ -136,9 +137,9 @@ export function renderPlantResult(data) { const sevColor = sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f59e0b' : sev === 'low' ? '#eab308' : '#22c55e'; const conf = (data.confidence_score * 100).toFixed(1); let html = `
-

🧪 Sonuç: ${data.diagnosis}

+

🧪 Sonuç: ${_escAttr(diagnosisLabel(data.diagnosis))}

Güven: %${conf}

-

Şiddet: ${sev}

+

Şiddet: ${_escAttr(severityLabel(sev))}

Model: ${data.model_version}

Tüm sınıf skorları
    `; diff --git a/frontend/src/lib/router.js b/frontend/src/lib/router.js index bc0c1da..c08d9eb 100644 --- a/frontend/src/lib/router.js +++ b/frontend/src/lib/router.js @@ -27,6 +27,10 @@ export const pageTitles = { * @param {Function} startHeroTipRotation - Hero tip rotation starter */ export function navigate(page, handlers, startHeroTipRotation) { + if (!document.getElementById(`page-${page}`) || !pageTitles[page]) { + console.warn(`navigate: bilinmeyen sayfa "${page}"`); + return; + } document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.nav-item').forEach(n => { n.classList.remove('active'); diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js index bbb208b..6923a79 100644 --- a/frontend/src/lib/utils.js +++ b/frontend/src/lib/utils.js @@ -24,13 +24,14 @@ export function _fmtNumber(v, decimals = 1) { } export function _escAttr(s) { - return String(s ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(/<>/g, '<').replace(/>/g, '>'); + return String(s ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } // ─── Toast Notifications ────────────────────────────────────── export function showToast(message, type = 'info', duration = 3500) { const container = document.getElementById('toastContainer'); + if (!container) return; const toast = document.createElement('div'); toast.className = `toast ${type}`; const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'; @@ -56,10 +57,11 @@ export function showToast(message, type = 'info', duration = 3500) { export function updateStatus(online) { const dot = document.getElementById('statusDot'); const text = document.getElementById('statusText'); - dot.className = `status-dot ${online ? 'online' : 'offline'}`; - text.textContent = online ? 'Sistem Aktif' : 'Bağlantı Yok'; + if (dot) dot.className = `status-dot ${online ? 'online' : 'offline'}`; + if (text) text.textContent = online ? 'Sistem Aktif' : 'Bağlantı Yok'; } export function updateClock() { - document.getElementById('clockDisplay').textContent = new Date().toLocaleTimeString('tr'); + const clockEl = document.getElementById('clockDisplay'); + if (clockEl) clockEl.textContent = new Date().toLocaleTimeString('tr'); } diff --git a/scripts/seed_demo_readings.py b/scripts/seed_demo_readings.py new file mode 100644 index 0000000..f31ddc8 --- /dev/null +++ b/scripts/seed_demo_readings.py @@ -0,0 +1,58 @@ +"""Demo hazırlık — mevcut sensörlere son 48 saatlik TAZE toprak nemi okuması ekler. + +Neden gerekli: dashboard 'son 24 saat' penceresi kullanır; seed okumaları zamanla +eskir (>24h) ve 'Veri yok' görünür. Bu script demo ÖNCESİ çalıştırılır. +Idempotent: her sensör için son 48h yeniden yazılır (dup birikmez). + +Kullanım: PYTHONPATH=. .venv/bin/python scripts/seed_demo_readings.py +""" + +import math +from datetime import UTC, datetime, timedelta + +from app.database import SessionLocal +from app.models.models import Sensor, SoilMoistureReading + +HOURS = 48 + + +def seed_readings() -> None: + db = SessionLocal() + try: + sensors = db.query(Sensor).all() + if not sensors: + print("⚠️ Sensör yok — önce seed_data.py çalıştır.") + return + now = datetime.now(UTC) + cutoff = now - timedelta(hours=HOURS) + added = 0 + for i, sensor in enumerate(sensors): + # idempotent: son 48h'ı temizle, yeniden yaz + db.query(SoilMoistureReading).filter( + SoilMoistureReading.sensor_id == sensor.id, + SoilMoistureReading.reading_timestamp >= cutoff, + ).delete(synchronize_session=False) + base = 45.0 + (i % 5) * 3.0 # sensöre göre 45-57 taban nem + for h in range(HOURS): + ts = now - timedelta(hours=h) + diurnal = 8.0 * math.sin((ts.hour / 24) * 2 * math.pi) # gün/gece dalgası + noise = (h * 7) % 5 - 2 # -2..+2 deterministik + moisture = max(15.0, min(90.0, base + diurnal + noise)) + temp = 18.0 + 5.0 * math.sin(((ts.hour - 4) / 24) * 2 * math.pi) + db.add( + SoilMoistureReading( + sensor_id=sensor.id, + reading_timestamp=ts, + moisture_percent=round(moisture, 1), + soil_temperature_c=round(temp, 1), + ) + ) + added += 1 + db.commit() + print(f"✓ {len(sensors)} sensör × {HOURS}h = {added} taze okuma eklendi (son {HOURS}h, idempotent).") + finally: + db.close() + + +if __name__ == "__main__": + seed_readings()