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:
@@ -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>
|
||||
|
||||
@@ -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> ` +
|
||||
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 => ({'<':'<','>':'>','&':'&'}[c])); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user