feat: Feedforward Phase I — DDL 2테이블 + DI 등록 + Tab 18 frontend (ff.js/html/css)

This commit is contained in:
windpacer
2026-05-31 17:31:42 +09:00
parent 7688757b21
commit e3167807b4
7 changed files with 389 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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>();

View 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}

View File

@@ -1,4 +1,7 @@
/* ── P&ID 추출 스타일 ───────────────────────────────────────── */
#pane-pid {
margin: 0 -16px;
}
#pane-pid .card-cap {
display: flex;
justify-content: space-between;

View File

@@ -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
View 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'; });
}

View 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>