From 1b6a717e76dbe3b683c92a76b46e90429a778e05 Mon Sep 17 00:00:00 2001 From: windpacer Date: Mon, 15 Jun 2026 15:30:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(report):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=88=ED=84=B0=20=EC=9B=B9=EB=B7=B0=20(=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=B9=B4=EB=93=9C=20=EA=B7=B8=EB=A6=AC=EB=93=9C)?= =?UTF-8?q?=20+=20=EC=95=8C=EB=9E=8C=C2=B7=EB=8F=99=ED=8A=B9=EC=84=B1=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운전원/관리자용 실시간 대시보드 — 한눈에 상태 파악. - 리포트 탭 최상단 ① 라이브 모니터: 컬럼 카드 그리드(상태 색상 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 --- src/Hc900Crawler/wwwroot/index.html | 4 +- src/Hc900Crawler/wwwroot/js/reports.js | 115 ++++++++++++++++++ src/Hc900Crawler/wwwroot/panes/reports.html | 23 +++- .../Hc900/Hc900LiveKpiService.cs | 11 +- 4 files changed, 145 insertions(+), 8 deletions(-) diff --git a/src/Hc900Crawler/wwwroot/index.html b/src/Hc900Crawler/wwwroot/index.html index cac5265..30dae1d 100644 --- a/src/Hc900Crawler/wwwroot/index.html +++ b/src/Hc900Crawler/wwwroot/index.html @@ -137,7 +137,7 @@
-
+
@@ -237,6 +237,6 @@ - + diff --git a/src/Hc900Crawler/wwwroot/js/reports.js b/src/Hc900Crawler/wwwroot/js/reports.js index 140095f..229ab1d 100644 --- a/src/Hc900Crawler/wwwroot/js/reports.js +++ b/src/Hc900Crawler/wwwroot/js/reports.js @@ -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 += ``; 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 ? '' + : `
+ 알람 ${al.length}  ` + + al.map(a => `${a.Severity === 'warning' ? '🟠' : '🟡'} ${a.Column} ${a.Rule} — ${esc2(a.Message || '')}`).join('') + `
`; + + // 카드 그리드 (컬럼 정렬) + 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 => `${a.Rule}`).join(' '); + + return `
+
+ ${col} + ● ${label} +
+
+ ${cv == null ? '—' : rpFmt(cv,'pct') + '%'} +
물질수지 폐합
+
+
+ IN${num('feed_qv','kg')} + OUT${num('out_total','kg')} + 생산${num('production_total','kg')} kg + 수율${num('yield_qv','ratio')} +
+ ${chips ? `
${chips}
` : ''} +
`; +} + +async function rmExpand(col) { + const box = document.getElementById('rmDetail'); + box.innerHTML = `
⏳ ${col} 상세...
`; + 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 = ` +
+
${col} 상세 + src=history_1s · ${today}
+
+
물질수지
+ IN ${rpFmt(e.feed_qv,'kg')} → OUT ${rpFmt(e.out_total,'kg')} kg
+ 제품 ${rpFmt(e.out0_qv,'kg')} · 경비 ${rpFmt(e.out1_qv,'kg')} · 중비 ${rpFmt(e.out2_qv,'kg')}
+ 폐합 ${clo.Status==='ok'?rpFmt(clo.Value,'pct')+'%':'N/A'}
+
제어품질 (하부온도)
+ 평균잔차 ${res.Status==='ok'?rpFmt(res.Value,'degC'):'N/A'}℃
+ sd ${rpFmt(res.Extra.sd,'degC')} · |잔차|>0.5℃ ${rpFmt(res.Extra.out_pct_0_5,'pct')}%
+
동특성 (밸브, history_1s)
+ ${dyn.Status==='ok'?`OP travel ${rpFmt(dyn.Value,'')} /h
헌팅주기 ${rpFmt(dyn.Extra.osc_period_s,'')}s · PV sd ${rpFmt(dyn.Extra.pv_sd,'degC')}`:`N/A (${esc2(dyn.Error||'')})`}
+
+
`; + } catch (e) { box.innerHTML = `❌ ${e.message||e}`; } +} + +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 => ({'<':'<','>':'>','&':'&'}[c])); } diff --git a/src/Hc900Crawler/wwwroot/panes/reports.html b/src/Hc900Crawler/wwwroot/panes/reports.html index b7ede29..623abc5 100644 --- a/src/Hc900Crawler/wwwroot/panes/reports.html +++ b/src/Hc900Crawler/wwwroot/panes/reports.html @@ -1,9 +1,22 @@ -
-

리포트 (P0)

+
+

리포트

- +
-

① 웹에서 바로 보기

+
+

① 라이브 모니터

+ + +
+
+
+
+
+ + +
+

② 웹에서 바로 보기 (기간 선택)

@@ -21,7 +34,7 @@
-

② 엑셀 폼으로 내보내기

+

③ 엑셀 폼으로 내보내기

엑셀 템플릿 셀에 {{ metric=mass_balance_closure; column=C-6111 }} 형태 토큰을 박아두면 선택 날짜 값으로 채워 다운로드합니다.

diff --git a/src/Infrastructure/Hc900/Hc900LiveKpiService.cs b/src/Infrastructure/Hc900/Hc900LiveKpiService.cs index 6693a88..d7776c5 100644 --- a/src/Infrastructure/Hc900/Hc900LiveKpiService.cs +++ b/src/Infrastructure/Hc900/Hc900LiveKpiService.cs @@ -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);