feat: Feedforward Phase I — DDL 2테이블 + DI 등록 + Tab 18 frontend (ff.js/html/css)
This commit is contained in:
@@ -1061,6 +1061,48 @@ public class ExperionDbService : IExperionDbService
|
||||
GROUP BY area_code ORDER BY area_code
|
||||
""");
|
||||
|
||||
// ── Feedforward advisory engine config tables ─────────────────────
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS ff_column_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
feed_tag TEXT NOT NULL,
|
||||
pressure_tag TEXT,
|
||||
level_tags TEXT,
|
||||
scan_sec DOUBLE PRECISION NOT NULL DEFAULT 2,
|
||||
feed_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 300,
|
||||
feed_move_thr_per_min DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
press_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 60,
|
||||
pressure_band DOUBLE PRECISION NOT NULL DEFAULT 1e9,
|
||||
settle_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
stale_sec DOUBLE PRECISION NOT NULL DEFAULT 120,
|
||||
product_key TEXT NOT NULL DEFAULT 'P',
|
||||
advisory_only BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
""");
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS ff_stream_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
column_id INTEGER NOT NULL REFERENCES ff_column_config(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
flow_tag TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
target_coeff DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
theta_up_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
theta_dn_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
tau_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
sp_min DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
sp_max DOUBLE PRECISION NOT NULL DEFAULT 1e9,
|
||||
rate_up_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9,
|
||||
rate_dn_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9,
|
||||
reflux_from_product BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
grade TEXT NOT NULL DEFAULT 'A',
|
||||
level_tag TEXT
|
||||
);
|
||||
ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS level_tag TEXT;
|
||||
""");
|
||||
|
||||
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,12 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastServic
|
||||
// ── Metadata Loader Service ───────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
|
||||
|
||||
// ── Feedforward Advisory Engine ───────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.FeedforwardEngine>();
|
||||
builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.IFeedforwardAdvisoryStore, ExperionCrawler.Infrastructure.Control.FeedforwardAdvisoryStore>();
|
||||
builder.Services.AddScoped<ExperionCrawler.Core.Application.Feedforward.IFeedforwardConfigStore, ExperionCrawler.Infrastructure.Control.FeedforwardConfigStore>();
|
||||
builder.Services.AddHostedService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>();
|
||||
|
||||
// ── P&ID Services ───────────────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
|
||||
builder.Services.AddScoped<ITagMappingService, TagMappingService>();
|
||||
|
||||
49
src/Web/wwwroot/css/ff.css
Normal file
49
src/Web/wwwroot/css/ff.css
Normal file
@@ -0,0 +1,49 @@
|
||||
.ff-wrap{padding:16px;color:var(--t1)}
|
||||
.ff-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}
|
||||
.ff-badge{font-size:12px;color:var(--t2);border:1px solid var(--bd);border-radius:10px;padding:2px 8px}
|
||||
.ff-dash{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:12px}
|
||||
.ff-col-card{background:var(--bg2);border:1px solid var(--bd);border-radius:8px;padding:12px}
|
||||
.ff-col-card.ff-disabled{opacity:.5}
|
||||
.ff-col-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||||
.ff-transient{background:#3a2e00;color:#ffd24d;padding:4px 8px;border-radius:4px;font-size:13px;margin:4px 0}
|
||||
.ff-tbl{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.ff-tbl th,.ff-tbl td{padding:3px 6px;border-bottom:1px solid var(--bd);text-align:left}
|
||||
.ff-num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ff-rec{font-weight:600;color:#7fd1ff}
|
||||
.ff-stale{opacity:.45}
|
||||
.ff-role-LevelDriven{color:#9aa}.ff-role-Monitor{color:#777}.ff-role-Commanded{color:#7fd1ff}
|
||||
.ff-grade-A{color:#4caf50}.ff-grade-B{color:#ffb300}.ff-grade-C{color:#ff5252}
|
||||
.ff-mb,.ff-note{font-size:12px;color:var(--t2);margin-top:6px}
|
||||
.ff-msg.err{color:#ff5252}
|
||||
.ff-cfg{margin-top:16px;border-top:1px solid var(--bd);padding-top:12px}
|
||||
.ff-cfg-bar{display:flex;gap:8px;align-items:center;margin-bottom:12px}
|
||||
.ff-cfg-item{padding:6px 0;border-bottom:1px solid var(--bd);font-size:13px}
|
||||
.ff-msg{font-size:12px;margin-left:8px}
|
||||
|
||||
/* ── 폼 에디터 모달 ───────────────────────────────── */
|
||||
.ff-modal{position:fixed;inset:0;z-index:900;display:flex;align-items:center;justify-content:center}
|
||||
.ff-modal-overlay{position:absolute;inset:0;background:rgba(0,0,0,.55)}
|
||||
.ff-modal-box{position:relative;background:var(--s2);border:1px solid var(--bd2);border-radius:var(--rl);width:min(1100px,95vw);max-height:92vh;display:flex;flex-direction:column}
|
||||
.ff-modal-hd{padding:14px 16px;border-bottom:1px solid var(--bd);font-weight:700;font-size:15px;color:var(--t0)}
|
||||
.ff-modal-id{font-weight:400;font-size:12px;color:var(--t2);margin-left:8px}
|
||||
.ff-modal-body{overflow-y:auto;flex:1;padding:16px}
|
||||
.ff-modal-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
|
||||
.ff-modal-col{display:flex;flex-direction:column;gap:8px}
|
||||
.ff-modal-col label{font-size:12px;color:var(--t2);display:flex;flex-direction:column;gap:2px}
|
||||
.ff-modal-col label input[type="checkbox"]{margin:6px 0}
|
||||
.ff-modal-sec-hd{display:flex;align-items:center;gap:8px;font-weight:700;font-size:13px;color:var(--t0);margin-bottom:8px}
|
||||
.ff-key-legend{font-weight:400;font-size:11px;color:var(--t2)}
|
||||
.ff-stream-tbl{font-size:11px}
|
||||
.ff-stream-tbl th,.ff-stream-tbl td{padding:2px 4px;white-space:nowrap}
|
||||
.ff-stream-tbl .inp{width:64px;min-width:0;padding:4px 6px;font-size:11px}
|
||||
.ff-stream-tbl select.inp{width:auto}
|
||||
.ff-stream-tbl td:first-child .inp{width:48px}
|
||||
.ff-stream-tbl td:nth-child(2) .inp{width:80px}
|
||||
.ff-stream-tbl td input[type="checkbox"]{margin:0}
|
||||
.ff-modal-ft{display:flex;gap:8px;align-items:center;justify-content:flex-end;padding:12px 16px;border-top:1px solid var(--bd)}
|
||||
.ff-modal-ft .ff-msg{margin-right:auto}
|
||||
.danger{color:var(--red);border:1px solid var(--red)}.danger:hover{background:var(--red);color:#fff}
|
||||
.ff-lvl-hint{font-size:10px;color:var(--t2);margin-left:4px;white-space:nowrap}
|
||||
.ff-lvl-by{font-size:10px;color:var(--t2);font-weight:400}
|
||||
.ff-lvl-tag{width:72px!important;font-size:10px!important;padding:2px 4px!important}
|
||||
.ff-desc{font-size:12px;color:var(--t3);line-height:1.4}
|
||||
@@ -1,4 +1,7 @@
|
||||
/* ── P&ID 추출 스타일 ───────────────────────────────────────── */
|
||||
#pane-pid {
|
||||
margin: 0 -16px;
|
||||
}
|
||||
#pane-pid .card-cap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<link rel="stylesheet" href="/lib/uPlot.min.css"/>
|
||||
<link rel="stylesheet" href="/css/docs.css"/>
|
||||
<link rel="stylesheet" href="/css/trend.css"/>
|
||||
<link rel="stylesheet" href="/css/ff.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
@@ -110,6 +111,10 @@
|
||||
<span class="ni">17</span>
|
||||
<span class="nl">트렌드</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="ff">
|
||||
<span class="ni">18</span>
|
||||
<span class="nl">유량 권장(FF)</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sb-foot">
|
||||
@@ -137,6 +142,7 @@
|
||||
<section class="pane" id="pane-write" data-src="/panes/write.html"></section>
|
||||
<section class="pane" id="pane-docs" data-src="/panes/docs.html"></section>
|
||||
<section class="pane" id="pane-trend" data-src="/panes/trend.html"></section>
|
||||
<section class="pane" id="pane-ff" data-src="/panes/ff.html"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -239,5 +245,6 @@
|
||||
<script src="/js/write.js"></script>
|
||||
<script src="/js/docs.js"></script>
|
||||
<script src="/js/trend.js"></script>
|
||||
<script src="/js/ff.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
263
src/Web/wwwroot/js/ff.js
Normal file
263
src/Web/wwwroot/js/ff.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터.
|
||||
Phase I: 인증 없음. 쓰기 API 추가 시 X-Kb-Token 인증 재도입. */
|
||||
paneInit.ff = ffInit;
|
||||
|
||||
let ffTimer = null;
|
||||
|
||||
async function ffApi(method, path, body) {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
const res = await fetch(path, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function ffInit() {
|
||||
if (ffTimer) { clearInterval(ffTimer); ffTimer = null; }
|
||||
await ffLoadDash();
|
||||
ffTimer = setInterval(ffLoadDash, 3000);
|
||||
|
||||
document.getElementById('ff-cfg-toggle').onclick = () => {
|
||||
const c = document.getElementById('ff-cfg');
|
||||
c.style.display = c.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
document.getElementById('ff-new').onclick = () => ffEditColumn(null);
|
||||
ffLoadConfig().catch(()=>{});
|
||||
}
|
||||
|
||||
// ── 대시보드 (공개) ──────────────────────────────────────────────
|
||||
async function ffLoadDash() {
|
||||
let data;
|
||||
try { data = await api('GET', '/api/ff/advisory'); }
|
||||
catch (e) { return; }
|
||||
const host = document.getElementById('ff-dash');
|
||||
if (!host) { clearInterval(ffTimer); ffTimer = null; return; }
|
||||
const cols = data.columns || [];
|
||||
if (!cols.length) { host.innerHTML = '<div class="ff-empty">활성 컬럼 없음</div>'; return; }
|
||||
host.innerHTML = cols.map(ffCard).join('');
|
||||
}
|
||||
|
||||
function ffTrendIco(t) { return t > 0 ? '▲' : t < 0 ? '▼' : '–'; }
|
||||
|
||||
function ffCard(c) {
|
||||
const rows = (c.streams || []).map(s => {
|
||||
const lvlTag = s.levelTag || '';
|
||||
const roleLabel = s.role === 'LevelDriven' && lvlTag
|
||||
? `LevelDriven<br><span class="ff-lvl-by">레벨: ${esc(lvlTag)}</span>`
|
||||
: s.role === 'Commanded' ? 'Commanded' : 'Monitor';
|
||||
return `<tr class="${s.valid ? '' : 'ff-stale'}">
|
||||
<td>${esc(s.key)}</td><td class="ff-tag">${esc(s.flowTag)}</td>
|
||||
<td><span class="ff-role ff-role-${esc(s.role)}">${roleLabel}</span></td>
|
||||
<td class="ff-num">${fmtVal(s.pv)}</td>
|
||||
<td class="ff-num ff-rec">${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)}</td>
|
||||
<td class="ff-num">${s.gap==null?'–':fmtVal(s.gap)}</td>
|
||||
<td>${ffTrendIco(s.trend)}</td>
|
||||
<td><span class="ff-grade ff-grade-${esc(s.grade)}">${esc(s.grade)}</span></td>
|
||||
</tr>`;}).join('');
|
||||
const banner = c.transient
|
||||
? `<div class="ff-transient">과도상태: ${esc(c.transientReason)} — 권장값 정착 대기</div>` : '';
|
||||
const mb = `물질수지: ${esc(c.massBalanceState)}` +
|
||||
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
|
||||
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
|
||||
return `
|
||||
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
|
||||
<div class="ff-col-head"><b>${esc(c.columnName)}</b>
|
||||
<span class="ff-feed">FEED ${fmtVal(c.feedFiltered)}</span>
|
||||
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
|
||||
${banner}
|
||||
<table class="ff-tbl"><thead><tr>
|
||||
<th>스트림</th><th>태그</th><th>역할</th><th>PV</th><th>권장 SP</th><th>Δ</th><th>추세</th><th>신뢰</th>
|
||||
</tr></thead><tbody>${rows}</tbody></table>
|
||||
<div class="ff-mb">${esc(mb)}</div>
|
||||
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 설정 에디터 ──────────────────────────────────────────────────
|
||||
function ffMsg(m, err) { const e=document.getElementById('ff-cfg-msg'); e.textContent=m; e.className='ff-msg'+(err?' err':''); }
|
||||
|
||||
async function ffLoadConfig() {
|
||||
const data = await ffApi('GET', '/api/ff/config');
|
||||
const host = document.getElementById('ff-cfg-list');
|
||||
host.innerHTML = (data.columns||[]).map(ffCfgRow).join('') || '<div class="ff-empty">설정 없음</div>';
|
||||
host.querySelectorAll('[data-edit]').forEach(b => b.onclick = () =>
|
||||
ffEditColumn(data.columns.find(c => c.id == b.dataset.edit)));
|
||||
host.querySelectorAll('[data-del]').forEach(b => b.onclick = () => ffDelete(b.dataset.del));
|
||||
}
|
||||
function ffCfgRow(c) {
|
||||
return `<div class="ff-cfg-item"><b>${esc(c.name)}</b> (id ${c.id}) — feed ${esc(c.feedTag)},
|
||||
스트림 ${c.streams.length}개, ${c.enabled?'활성':'비활성'}
|
||||
<button class="btn sm" data-edit="${c.id}">편집</button>
|
||||
<button class="btn sm danger" data-del="${c.id}">삭제</button></div>`;
|
||||
}
|
||||
async function ffDelete(id) {
|
||||
if (!confirm(`컬럼 ${id} 삭제?`)) return;
|
||||
try { await ffApi('DELETE', `/api/ff/config/${id}`); await ffLoadConfig(); ffMsg('삭제됨'); }
|
||||
catch (e) { ffMsg('삭제 실패', true); }
|
||||
}
|
||||
|
||||
// ── 폼 에디터 모달 ──────────────────────────────────────────────
|
||||
const FF_ROLES = ['Commanded','LevelDriven','Monitor'];
|
||||
const FF_GRADES = ['A','B','C'];
|
||||
const FF_KEY_DESC = { P:'주생성물(Feedforward 계산)', R:'환류(R_f × P_sp)', D:'유출액(LevelDriven 기대치)', B:'탑저(LevelDriven 기대치)' };
|
||||
|
||||
function ffEditColumn(c) {
|
||||
const existing = document.getElementById('ff-modal');
|
||||
if (existing) existing.remove();
|
||||
const isNew = !c;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'ff-modal';
|
||||
modal.className = 'ff-modal';
|
||||
|
||||
const def = isNew
|
||||
? { name:'', enabled:false, feedTag:'', pressureTag:'',
|
||||
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5,
|
||||
pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P',
|
||||
streams:[
|
||||
{key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A'},
|
||||
{key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A'},
|
||||
{key:'D',flowTag:'',role:'LevelDriven',levelTag:'lica-6113',targetCoeff:0.02,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'},
|
||||
{key:'B',flowTag:'',role:'LevelDriven',levelTag:'li-6111',targetCoeff:0.03,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'}
|
||||
] }
|
||||
: { ...c, pressureTag: c.pressureTag||'' };
|
||||
|
||||
const colHtml = `
|
||||
<div class="ff-modal-col">
|
||||
<label>컬럼명 <input class="inp" id="ff-f-name" value="${esc(def.name)}"></label>
|
||||
<label><input type="checkbox" id="ff-f-enabled" ${def.enabled?'checked':''}> 활성</label>
|
||||
<label>Feed 태그 <input class="inp" id="ff-f-feedTag" value="${esc(def.feedTag)}"></label>
|
||||
<label>압력 태그 <input class="inp" id="ff-f-pressureTag" value="${esc(def.pressureTag)}"></label>
|
||||
<label>Product Key <input class="inp" id="ff-f-productKey" value="${esc(def.productKey)}"></label>
|
||||
</div>
|
||||
<div class="ff-modal-col">
|
||||
<label><span class="ff-desc">Scan(초): 계산 주기 — 빠를수록 민감하나 부하 증가</span><input class="inp" type="number" id="ff-f-scanSec" value="${def.scanSec}"></label>
|
||||
<label><span class="ff-desc">Feed 필터 τ(초): 원료투입량 필터 — 잦은 변화 안정화로 헌팅 방지</span><input class="inp" type="number" id="ff-f-feedFilterTauSec" value="${def.feedFilterTauSec}"></label>
|
||||
<label><span class="ff-desc">Feed 변동 임계(/분): 원료 투입량 분당 변화율 — 초과 시 과도상태 진입</span><input class="inp" type="number" id="ff-f-feedMoveThresholdPerMin" value="${def.feedMoveThresholdPerMin}"></label>
|
||||
<label><span class="ff-desc">압력 필터 τ(초): 진공압 필터 — 잦은 변화 안정화로 헌팅 방지</span><input class="inp" type="number" id="ff-f-pressFilterTauSec" value="${def.pressFilterTauSec}"></label>
|
||||
<label><span class="ff-desc">Pressure Band: 진공 설정값과 현재값 상하 변동폭 판정 기준</span><input class="inp" type="number" id="ff-f-pressureBand" value="${def.pressureBand}"></label>
|
||||
<label><span class="ff-desc">Settle(초): 안정화 판단 기준 시간 — 이 시간 동안 안정 시 과도상태 해제</span><input class="inp" type="number" id="ff-f-settleSec" value="${def.settleSec}"></label>
|
||||
<label><span class="ff-desc">Stale(초): 데이터 유효시간 — 마지막 갱신 후 이 시간 초과 시 사용 안 함</span><input class="inp" type="number" id="ff-f-staleSec" value="${def.staleSec}"></label>
|
||||
</div>`;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="ff-modal-overlay"></div>
|
||||
<div class="ff-modal-box">
|
||||
<div class="ff-modal-hd">${isNew?'새 컬럼':'컬럼 편집'} <span class="ff-modal-id">${isNew?'':'(id '+c.id+')'}</span></div>
|
||||
<div class="ff-modal-body">
|
||||
<div class="ff-modal-grid">${colHtml}</div>
|
||||
<div class="ff-modal-sec">
|
||||
<div class="ff-modal-sec-hd">스트림
|
||||
<span class="ff-key-legend">P=주생성물 R=환류(P rec) D=유출액 B=탑저</span>
|
||||
<button class="btn sm" id="ff-stream-add">+ 추가</button>
|
||||
</div>
|
||||
<table class="ff-tbl ff-stream-tbl">
|
||||
<thead><tr>
|
||||
<th>Key</th><th>Flow 태그</th><th>역할</th><th>레벨태그</th><th>K</th><th>θ_up</th><th>θ_dn</th><th>τ</th>
|
||||
<th>SP_min</th><th>SP_max</th><th>Rate_up</th><th>Rate_dn</th><th>환류</th><th>신뢰</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="ff-stream-body">
|
||||
${def.streams.map((s,i) => ffStreamRow(s,i)).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ff-modal-ft">
|
||||
<span id="ff-modal-msg" class="ff-msg"></span>
|
||||
<button class="btn-b" id="ff-modal-cancel">취소</button>
|
||||
<button class="btn-a" id="ff-modal-save">${isNew?'생성':'저장'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('ff-stream-add').onclick = () => {
|
||||
const tb = document.getElementById('ff-stream-body');
|
||||
const i = tb.children.length;
|
||||
tb.insertAdjacentHTML('beforeend', ffStreamRow({
|
||||
key:'',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0,thetaUpSec:0,thetaDnSec:0,
|
||||
tauSec:0,spMin:0,spMax:1e9,rateUpPerMin:1e9,rateDnPerMin:1e9,
|
||||
refluxFromProduct:false,grade:'A'
|
||||
}, i));
|
||||
ffWireStreamRow(tb.lastElementChild);
|
||||
};
|
||||
|
||||
document.querySelectorAll('#ff-stream-body tr').forEach(ffWireStreamRow);
|
||||
document.getElementById('ff-modal-cancel').onclick = () => modal.remove();
|
||||
document.getElementById('ff-modal-save').onclick = () => ffSaveForm(c?.id);
|
||||
document.querySelector('.ff-modal-overlay').onclick = () => modal.remove();
|
||||
}
|
||||
|
||||
function ffStreamRow(s, i) {
|
||||
const roleOpts = FF_ROLES.map(r => `<option ${r===s.role?'selected':''}>${r}</option>`).join('');
|
||||
const gradeOpts = FF_GRADES.map(g => `<option ${g===s.grade?'selected':''}>${g}</option>`).join('');
|
||||
const desc = FF_KEY_DESC[s.key] || '';
|
||||
const lvlTagHtml = s.role==='LevelDriven'
|
||||
? `<input class="inp ff-si ff-lvl-tag" value="${esc(s.levelTag||'')}" data-idx="${i}" data-f="levelTag" placeholder="레벨태그">`
|
||||
: `<input type="hidden" data-idx="${i}" data-f="levelTag" value="">`;
|
||||
return `<tr>
|
||||
<td><input class="inp ff-si" value="${esc(s.key)}" title="${esc(desc)}" data-idx="${i}" data-f="key"></td>
|
||||
<td><input class="inp ff-si" value="${esc(s.flowTag)}" data-idx="${i}" data-f="flowTag"></td>
|
||||
<td><select class="inp ff-si" data-idx="${i}" data-f="role">${roleOpts}</select></td>
|
||||
<td>${lvlTagHtml}</td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.targetCoeff}" data-idx="${i}" data-f="targetCoeff"></td>
|
||||
<td><input class="inp ff-si" type="number" value="${s.thetaUpSec}" data-idx="${i}" data-f="thetaUpSec"></td>
|
||||
<td><input class="inp ff-si" type="number" value="${s.thetaDnSec}" data-idx="${i}" data-f="thetaDnSec"></td>
|
||||
<td><input class="inp ff-si" type="number" value="${s.tauSec}" data-idx="${i}" data-f="tauSec"></td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.spMin}" data-idx="${i}" data-f="spMin"></td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.spMax}" data-idx="${i}" data-f="spMax"></td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.rateUpPerMin}" data-idx="${i}" data-f="rateUpPerMin"></td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.rateDnPerMin}" data-idx="${i}" data-f="rateDnPerMin"></td>
|
||||
<td><input type="checkbox" ${s.refluxFromProduct?'checked':''} data-idx="${i}" data-f="refluxFromProduct"></td>
|
||||
<td><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></td>
|
||||
<td><button class="btn sm danger ff-stream-del" data-idx="${i}">✕</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function ffWireStreamRow(tr) {
|
||||
const del = tr.querySelector('.ff-stream-del');
|
||||
if (del) del.onclick = () => { tr.remove(); };
|
||||
}
|
||||
|
||||
function ffSaveForm(existingId) {
|
||||
const g = id => document.getElementById(id);
|
||||
const body = {
|
||||
id: existingId || 0,
|
||||
name: g('ff-f-name').value,
|
||||
enabled: g('ff-f-enabled').checked,
|
||||
feedTag: g('ff-f-feedTag').value,
|
||||
pressureTag: g('ff-f-pressureTag').value || null,
|
||||
scanSec: +g('ff-f-scanSec').value,
|
||||
feedFilterTauSec: +g('ff-f-feedFilterTauSec').value,
|
||||
feedMoveThresholdPerMin: +g('ff-f-feedMoveThresholdPerMin').value,
|
||||
pressFilterTauSec: +g('ff-f-pressFilterTauSec').value,
|
||||
pressureBand: +g('ff-f-pressureBand').value,
|
||||
settleSec: +g('ff-f-settleSec').value,
|
||||
staleSec: +g('ff-f-staleSec').value,
|
||||
productKey: g('ff-f-productKey').value,
|
||||
advisoryOnly: true,
|
||||
streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => {
|
||||
const v = (sel, f) => {
|
||||
const el = tr.querySelector(`[data-f="${f}"]`);
|
||||
if (!el) return '';
|
||||
if (el.type === 'checkbox') return el.checked;
|
||||
if (el.tagName === 'SELECT') return el.value;
|
||||
return el.value;
|
||||
};
|
||||
return {
|
||||
key: v(null,'key'), flowTag: v(null,'flowTag'), role: v(null,'role'),
|
||||
levelTag: v(null,'levelTag') || null,
|
||||
targetCoeff: +v(null,'targetCoeff'), thetaUpSec: +v(null,'thetaUpSec'),
|
||||
thetaDnSec: +v(null,'thetaDnSec'), tauSec: +v(null,'tauSec'),
|
||||
spMin: +v(null,'spMin'), spMax: +v(null,'spMax'),
|
||||
rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'),
|
||||
refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade')
|
||||
};
|
||||
})
|
||||
};
|
||||
if (existingId) body.id = existingId;
|
||||
|
||||
const msg = g('ff-modal-msg');
|
||||
ffApi('POST', '/api/ff/config', body)
|
||||
.then(() => { document.getElementById('ff-modal').remove(); ffLoadConfig(); ffMsg('저장됨'); })
|
||||
.catch(e => { msg.textContent = '저장 실패: '+e.message; msg.className = 'ff-msg err'; });
|
||||
}
|
||||
19
src/Web/wwwroot/panes/ff.html
Normal file
19
src/Web/wwwroot/panes/ff.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="ff-wrap">
|
||||
<div class="ff-head">
|
||||
<h2>측류추출 유량 권장 (Advisory · 보조지표)</h2>
|
||||
<span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span>
|
||||
<button id="ff-cfg-toggle" class="btn">설정 ▾</button>
|
||||
</div>
|
||||
|
||||
<!-- 권장 SP 대시보드 (공개 읽기) -->
|
||||
<div id="ff-dash" class="ff-dash"><div class="ff-empty">불러오는 중…</div></div>
|
||||
|
||||
<!-- 설정 에디터 (Phase I: 인증 없음) -->
|
||||
<div id="ff-cfg" class="ff-cfg" style="display:none">
|
||||
<div class="ff-cfg-bar">
|
||||
<button id="ff-new" class="btn">+ 컬럼</button>
|
||||
<span id="ff-cfg-msg" class="ff-msg"></span>
|
||||
</div>
|
||||
<div id="ff-cfg-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user