fix(ui,health): move source-health log to bottom of page; one row per source (latest)

- Health panel relocated below Backtest (last section) per owner — it's
  operational detail, not an investment view.
- scheduler._load_source_health now returns only the LATEST entry per source
  key (was returning the full refresh history, so siamchart_vintages and
  price_snapshot appeared as 2 rows). Verified: 11 sources / 11 unique rows.
- frontend v-for drops slice(0,20) and keys by source key.

Verified in-browser: #health is the last section; 11 unique source rows.
This commit is contained in:
Kunthawat Greethong
2026-08-31 17:03:07 +07:00
parent 4c32e2b737
commit 2b7a065ae5
4 changed files with 66 additions and 44 deletions

View File

@@ -511,7 +511,12 @@ class AppDataScheduler:
return "other"
def _load_source_health(self, limit: int = 200) -> list[dict]:
"""Return the most recent source-health entries (for the API/UI)."""
"""Return the most recent source-health entries (for the API/UI).
Only the LATEST entry per source key is returned (the owner's rule: the
health panel should show one row per source with its last outcome, not a
history of every refresh). Entries are ordered newest-first.
"""
import json
path = self._snap_dir / "source_health.json"
if not path.is_file():
@@ -522,7 +527,21 @@ class AppDataScheduler:
return []
if not isinstance(data, list):
return []
return data[:limit]
# newest-first by "at" (the store writes append-only, newest last, so
# iterate from the end); keep only the first occurrence of each key.
seen: set = set()
latest: list[dict] = []
for entry in reversed(data):
if not isinstance(entry, dict):
continue
key = entry.get("key")
if key is None or key in seen:
continue
seen.add(key)
latest.append(entry)
if len(latest) >= limit:
break
return latest
@staticmethod
def _now() -> str:

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b1018" />
<title>SET50 Signal Lab</title>
<script type="module" crossorigin src="/assets/index-ePg0o_Uc.js"></script>
<script type="module" crossorigin src="/assets/index-C2MF9WUu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BiR2h3Pv.css">
</head>
<body>

View File

@@ -698,42 +698,6 @@ onMounted(async () => { await loadDashboard(); await Promise.all([loadBacktestRu
</div>
</section>
<section class="panel health-panel" id="health">
<div class="panel-header signal-header">
<div>
<div class="section-kicker">สถานะการดงขอม</div>
<h2>Log สถานะแหลงขอม</h2>
<p class="panel-subtitle">ผลการดงขอมลครงลาสดของแตละแหล โดยระบบวเคราะหสาเหตใหตโนม (เครอขาย / หมดเวลา / หนาเวบเปลยนโครงสราง / ปแบบขอม เปนต) กดป "คัดลอก" เพ copy สาเหตไปแจ/ตรวจสอบไดนท.</p>
</div>
</div>
<div v-if="sourceHealth.length === 0" class="empty-research muted-cell">ยังไม่มี log รอรอบ refresh ถัดไป (ปกติ ~ทุกวันสำหรับราคา, ~รายเดือน/ไตรมาสสำหรับปัจจัย).</div>
<div v-else>
<table class="source-table">
<thead><tr><th>แหล</th><th>ผลลพธ</th><th>สาเหต</th><th>เวลา</th><th></th></tr></thead>
<tbody>
<tr v-for="(e, i) in sourceHealth.slice(0, 20)" :key="i">
<td class="source-name">{{ e.label }}<div class="muted-cell" style="font-size:11px">{{ e.key }}</div></td>
<td>
<span v-if="e.ok" class="status-tag" style="background:#1a7f37;color:#fff">OK</span>
<span v-else class="status-tag warning-tag">FAIL</span>
</td>
<td>
<template v-if="e.ok"></template>
<template v-else>
<div>{{ healthCategoryLabel(e.category) }}{{ appendHealthRationale(e) }}</div>
<div v-if="e.detail" class="muted-cell" style="font-size:11px;word-break:break-word">{{ e.detail.slice(0, 160) }}</div>
</template>
</td>
<td class="muted-cell">{{ e.at ? formatDate(e.at) : '—' }}</td>
<td>
<button v-if="!e.ok" class="primary-btn" style="padding:2px 8px" @click="copyHealthLine(e)">คัดลอกสาเหตุ</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Sections 01-04 (tourism-only surprise, provenance, signal table, frozen research) removed per user: superseded by the 3-theme panel + sources table above. -->
<section class="panel sim-panel" id="suggestion">
<div class="panel-header signal-header">
@@ -872,6 +836,45 @@ onMounted(async () => { await loadDashboard(); await Promise.all([loadBacktestRu
</div>
</section>
<!-- Health log moved to the very bottom of the page per owner (below
Backtest): operational detail, not an investment view. The backend
already returns ONE row per source (latest outcome only). -->
<section class="panel health-panel" id="health">
<div class="panel-header signal-header">
<div>
<div class="section-kicker">สถานะการดงขอม</div>
<h2>Log สถานะแหลงขอม</h2>
<p class="panel-subtitle">ผลการดงขอมลครงลาสดของแตละแหล (1 แถวตอแหล) โดยระบบวเคราะหสาเหตใหตโนม (เครอขาย / หมดเวลา / หนาเวบเปลยนโครงสราง / ปแบบขอม เปนต) กดป "คัดลอก" เพ copy สาเหตไปแจ/ตรวจสอบไดนท.</p>
</div>
</div>
<div v-if="sourceHealth.length === 0" class="empty-research muted-cell">ยังไม่มี log รอรอบ refresh ถัดไป (ปกติ ~ทุกวันสำหรับราคา, ~รายเดือน/ไตรมาสสำหรับปัจจัย).</div>
<div v-else>
<table class="source-table">
<thead><tr><th>แหล</th><th>ผลลพธ</th><th>สาเหต</th><th>เวลา</th><th></th></tr></thead>
<tbody>
<tr v-for="e in sourceHealth" :key="e.key">
<td class="source-name">{{ e.label }}<div class="muted-cell" style="font-size:11px">{{ e.key }}</div></td>
<td>
<span v-if="e.ok" class="status-tag" style="background:#1a7f37;color:#fff">OK</span>
<span v-else class="status-tag warning-tag">FAIL</span>
</td>
<td>
<template v-if="e.ok"></template>
<template v-else>
<div>{{ healthCategoryLabel(e.category) }}{{ appendHealthRationale(e) }}</div>
<div v-if="e.detail" class="muted-cell" style="font-size:11px;word-break:break-word">{{ e.detail.slice(0, 160) }}</div>
</template>
</td>
<td class="muted-cell">{{ e.at ? formatDate(e.at) : '—' }}</td>
<td>
<button v-if="!e.ok" class="primary-btn" style="padding:2px 8px" @click="copyHealthLine(e)">คัดลอกสาเหตุ</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 05 / บันทึกการวิเคราะห์ removed per user (redundant with theme-panel narratives) -->
</template>
</main>