feat(report): 라이브 모니터 웹뷰 (상태 카드 그리드) + 알람·동특성 패널

운전원/관리자용 실시간 대시보드 — 한눈에 상태 파악.

- 리포트 탭 최상단 ① 라이브 모니터: 컬럼 카드 그리드(상태 색상 normal/idle/cleaning/
  drawdown/error), 폐합%가 hero(98~102 초록/이탈 빨강), IN/OUT·생산·수율, 알람 chip.
- 상단 알람 배너(severity 색상), 자동갱신 15s 토글, 카드 클릭 → 상세 패널
  (물질수지 IN/OUT 분해 + 제어품질 잔차 + 동특성 OP travel/헌팅주기, history_1s).
- 데이터: /api/report/live + /alerts + /summary(history_1s) + dynamics.
- LiveKpiService: 카드용 폐합 성분(feed_qv/out_total)도 live_kpi 저장(closure ok 시).

검증: 새 JS 서빙, /live 28행(state=idle 정상), /alerts C-8111 drawdown 활성.
데모 sim은 totalizer 평평이라 폐합/생산 "—"(카드·색상·알람·동특성 동작).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-15 15:30:47 +09:00
parent 9d4269a02c
commit 1b6a717e76
4 changed files with 145 additions and 8 deletions

View File

@@ -137,7 +137,7 @@
<section class="pane" id="pane-ff" data-src="/panes/ff.html?v=20260604"></section>
<section class="pane" id="pane-steam" data-src="/panes/steam.html?v=20260606"></section>
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260612"></section>
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260615"></section>
</main>
</div>
@@ -237,6 +237,6 @@
<script src="/js/trend.js?v=20260611"></script>
<script src="/js/ff.js?v=20260604"></script>
<script src="/js/steam.js?v=20260606"></script>
<script src="/js/reports.js?v=20260612"></script>
<script src="/js/reports.js?v=20260615"></script>
</body>
</html>

View File

@@ -8,6 +8,9 @@ paneInit['reports'] = function () {
const tog = (sel, wrap) => { const s = $(sel), w = $(wrap); if (s && w) { s.onchange = () => w.style.display = s.value === 'fast_record' ? '' : 'none'; s.onchange(); } };
tog('rvSource', 'rvSessWrap'); tog('rpSource', 'rpSessionWrap');
// ① 라이브 모니터 — 자동갱신 폴러
rmStart();
// ── ① 웹에서 바로 보기 ──
const col = $('rvCol'), date = $('rvDate'), go = $('rvGo'), out = $('rvOut');
if (date && !date.value) date.value = yKst();
@@ -118,3 +121,115 @@ function renderSummary(d) {
html += `</table>`;
return html;
}
/* ── ① 라이브 모니터 (상태 카드 그리드) ─────────────────────── */
const RM_COLOR = { normal:'#2ea043', idle:'#8b949e', error:'#e5534b', cleaning:'#3a6ea5', drawdown:'#d29922' };
const RM_LABEL = { normal:'정상', idle:'미가동', error:'오류', cleaning:'세정', drawdown:'drawdown' };
function rmStart() {
const auto = document.getElementById('rmAuto');
if (window._rmTimer) { clearInterval(window._rmTimer); window._rmTimer = null; }
rmRefresh();
window._rmTimer = setInterval(() => {
if (!document.getElementById('pane-reports')?.classList.contains('active')) return;
if (!auto || auto.checked) rmRefresh();
}, 15000);
}
async function rmRefresh() {
const grid = document.getElementById('rmGrid'); if (!grid) return;
try {
const [live, alerts] = await Promise.all([
fetch('/api/report/live').then(r => r.json()),
fetch('/api/report/alerts').then(r => r.json())
]);
// group
const byCol = {}, alByCol = {};
for (const it of (live.Items || [])) (byCol[it.Column] = byCol[it.Column] || {})[it.Kpi] = it;
for (const a of (alerts.Items || [])) (alByCol[a.Column] = alByCol[a.Column] || []).push(a);
// 알람 배너
const banner = document.getElementById('rmAlerts');
const al = alerts.Items || [];
banner.innerHTML = al.length === 0 ? ''
: `<div style="border:1px solid #d29922;border-radius:8px;padding:8px 12px;background:rgba(210,153,34,.08)">
<b>알람 ${al.length}</b> &nbsp;` +
al.map(a => `<span style="margin-right:14px">${a.Severity === 'warning' ? '🟠' : '🟡'} <b>${a.Column}</b> ${a.Rule}${esc2(a.Message || '')}</span>`).join('') + `</div>`;
// 카드 그리드 (컬럼 정렬)
const cols = Object.keys(byCol).sort();
grid.innerHTML = cols.map(c => rmCard(c, byCol[c], alByCol[c] || [])).join('');
cols.forEach(c => { const el = document.getElementById('rmcard-' + cssId(c)); if (el) el.onclick = () => rmExpand(c); });
document.getElementById('rmStatus').textContent = '⟳ 갱신 ' + new Date().toLocaleTimeString();
} catch (e) {
document.getElementById('rmStatus').textContent = '⚠ ' + (e.message || e);
}
}
function rmCard(col, k, alerts) {
const st = (k.mass_balance_closure || k.production_total || {}).State || 'idle';
// 알람 모드 우선 표시
const modeAlert = alerts.find(a => a.Rule === 'cleaning' || a.Rule === 'drawdown');
const cardState = modeAlert ? modeAlert.Rule : st;
const color = RM_COLOR[cardState] || '#8b949e';
const label = RM_LABEL[cardState] || cardState;
const clo = k.mass_balance_closure;
const cv = clo && clo.Status === 'ok' && clo.Value != null ? clo.Value : null;
const cloColor = cv == null ? '#8b949e' : (cv >= 98 && cv <= 102 ? '#2ea043' : '#e5534b');
const num = (kpi, kind) => { const m = k[kpi]; return m && m.Status === 'ok' && m.Value != null ? rpFmt(m.Value, kind) : '—'; };
const chips = alerts.map(a => `<span style="font-size:11px;padding:1px 6px;border-radius:8px;background:${a.Severity==='warning'?'#e5534b':'#d29922'};color:#fff">${a.Rule}</span>`).join(' ');
return `<div id="rmcard-${cssId(col)}" style="border:1px solid ${color};border-radius:10px;padding:12px;cursor:pointer;background:rgba(255,255,255,.02)">
<div style="display:flex;justify-content:space-between;align-items:center">
<b>${col}</b>
<span style="font-size:12px;color:${color}">● ${label}</span>
</div>
<div style="text-align:center;margin:8px 0">
<span style="font-size:28px;font-weight:700;color:${cloColor}">${cv == null ? '—' : rpFmt(cv,'pct') + '%'}</span>
<div style="font-size:11px;color:var(--t2)">물질수지 폐합</div>
</div>
<div style="font-size:12px;color:var(--t2);display:grid;grid-template-columns:1fr 1fr;gap:2px 10px">
<span>IN</span><span style="text-align:right;color:var(--t1,#ddd)">${num('feed_qv','kg')}</span>
<span>OUT</span><span style="text-align:right;color:var(--t1,#ddd)">${num('out_total','kg')}</span>
<span>생산</span><span style="text-align:right;color:var(--t1,#ddd)">${num('production_total','kg')} kg</span>
<span>수율</span><span style="text-align:right;color:var(--t1,#ddd)">${num('yield_qv','ratio')}</span>
</div>
${chips ? `<div style="margin-top:8px">${chips}</div>` : ''}
</div>`;
}
async function rmExpand(col) {
const box = document.getElementById('rmDetail');
box.innerHTML = `<div class="mono" style="color:var(--t2)">⏳ ${col} 상세...</div>`;
try {
const today = new Date(Date.now() + 9*3600e3).toISOString().slice(0,10);
const sum = await fetch(`/api/report/summary?column=${encodeURIComponent(col)}&date=${today}&source=history_1s`).then(r=>r.json());
const dyn = await fetch('/api/report/metric', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({Column:col, Metric:'dynamics', PeriodDateKst:today, SourceTable:'history_1s'})}).then(r=>r.json());
const clo = (sum.Metrics||[]).find(m=>m.Metric==='mass_balance_closure') || {};
const e = clo.Extra || {};
const res = (sum.Metrics||[]).find(m=>m.Metric==='control_residual') || {Extra:{}};
box.innerHTML = `
<div style="border:1px solid var(--bd,#444);border-radius:10px;padding:14px;margin-top:4px">
<div style="display:flex;justify-content:space-between"><b>${col} 상세</b>
<span class="mono" style="font-size:11px;color:var(--t2)">src=history_1s · ${today}</span></div>
<div style="display:flex;gap:24px;flex-wrap:wrap;margin-top:8px;font-size:13px">
<div><div style="color:var(--t2);font-size:11px">물질수지</div>
IN ${rpFmt(e.feed_qv,'kg')} → OUT ${rpFmt(e.out_total,'kg')} kg<br>
제품 ${rpFmt(e.out0_qv,'kg')} · 경비 ${rpFmt(e.out1_qv,'kg')} · 중비 ${rpFmt(e.out2_qv,'kg')}<br>
폐합 <b>${clo.Status==='ok'?rpFmt(clo.Value,'pct')+'%':'N/A'}</b></div>
<div><div style="color:var(--t2);font-size:11px">제어품질 (하부온도)</div>
평균잔차 ${res.Status==='ok'?rpFmt(res.Value,'degC'):'N/A'}℃<br>
sd ${rpFmt(res.Extra.sd,'degC')} · |잔차|>0.5℃ ${rpFmt(res.Extra.out_pct_0_5,'pct')}%</div>
<div><div style="color:var(--t2);font-size:11px">동특성 (밸브, history_1s)</div>
${dyn.Status==='ok'?`OP travel ${rpFmt(dyn.Value,'')} /h<br>헌팅주기 ${rpFmt(dyn.Extra.osc_period_s,'')}s · PV sd ${rpFmt(dyn.Extra.pv_sd,'degC')}`:`N/A (${esc2(dyn.Error||'')})`}</div>
</div>
</div>`;
} catch (e) { box.innerHTML = `<span class="mono" style="color:#e66">❌ ${e.message||e}</span>`; }
}
function cssId(s) { return s.replace(/[^a-zA-Z0-9]/g, '_'); }
function esc2(s) { return typeof esc === 'function' ? esc(s) : String(s).replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c])); }

View File

@@ -1,9 +1,22 @@
<div class="report-pane" style="padding:20px;max-width:880px">
<h2 style="margin-top:0">리포트 (P0)</h2>
<div class="report-pane" style="padding:20px;max-width:1080px">
<h2 style="margin-top:0">리포트</h2>
<!-- ── ① 웹에서 바로 보기 ──────────────────────────────── -->
<!-- ── ① 라이브 모니터 (상태 카드 그리드) ──────────────── -->
<section style="margin-bottom:28px">
<h3 style="margin:0 0 8px">① 웹에서 바로 보기</h3>
<div style="display:flex;align-items:center;gap:12px">
<h3 style="margin:0">① 라이브 모니터</h3>
<span id="rmStatus" class="mono" style="font-size:12px;color:var(--t2)"></span>
<label style="font-size:12px;color:var(--t2);margin-left:auto">
<input type="checkbox" id="rmAuto" checked> 자동갱신 15s</label>
</div>
<div id="rmAlerts" style="margin-top:10px"></div>
<div id="rmGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:10px"></div>
<div id="rmDetail" style="margin-top:12px"></div>
</section>
<!-- ── ② 웹에서 바로 보기 ──────────────────────────────── -->
<section style="margin-bottom:28px">
<h3 style="margin:0 0 8px">② 웹에서 바로 보기 (기간 선택)</h3>
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
<label>컬럼<br><select id="rvCol" style="margin-top:4px"></select></label>
<label>날짜(KST)<br><input type="date" id="rvDate" style="margin-top:4px"></label>
@@ -21,7 +34,7 @@
<!-- ── ② 엑셀 폼으로 내보내기 ──────────────────────────── -->
<section style="border-top:1px solid var(--bd,#333);padding-top:18px">
<h3 style="margin:0 0 8px"> 엑셀 폼으로 내보내기</h3>
<h3 style="margin:0 0 8px"> 엑셀 폼으로 내보내기</h3>
<p style="color:var(--t2);font-size:13px;margin-top:0">
엑셀 템플릿 셀에 <code>{{ metric=mass_balance_closure; column=C-6111 }}</code> 형태 토큰을 박아두면 선택 날짜 값으로 채워 다운로드합니다.
</p>

View File

@@ -78,8 +78,17 @@ public class Hc900LiveKpiService : BackgroundService
written++;
}
// P1d: 실시간 알람 (운전모드 + 폐합 이탈)
// 카드 표시용 폐합 성분(IN/OUT) 저장
var closure = results.First(r => r.Metric == "mass_balance_closure");
if (closure.Status == "ok")
{
if (closure.Extra.TryGetValue("feed_qv", out var fq) && fq is double fqv)
await UpsertAsync(conn, col, "feed_qv", todayKst, fqv, "kg", state, null, "ok", stoppingToken);
if (closure.Extra.TryGetValue("out_total", out var ot) && ot is double otv)
await UpsertAsync(conn, col, "out_total", todayKst, otv, "kg", state, null, "ok", stoppingToken);
}
// P1d: 실시간 알람 (운전모드 + 폐합 이탈)
await EvaluateAlertsAsync(conn, col, closure, stoppingToken);
}
_logger.LogDebug("[LiveKpi] {N}개 KPI 갱신 @ {Day}", written, todayKst);