515 lines
26 KiB
Markdown
515 lines
26 KiB
Markdown
# 측류추출 통합유량 — Phase II UI 구현 코딩 (Tab 18: 설정 + 권장 SP 대시보드)
|
||
|
||
> **성격**: Phase I advisory 엔진(`...-PhaseI.md`)의 **Web UI 코딩 명세 + 검증 절차**.
|
||
> 감독자가 `diagnosis-checklist.md` 8단계로 진단한 뒤 반영. **advisory 불변식 유지** — 제어 레지스터 쓰기 0건.
|
||
> Phase II는 **운전원이 경험상수를 공급**하고 **권장 SP를 화면에서 본다**(수동 인가). 자동 쓰기는 Phase III.
|
||
|
||
**Phase II 범위 분리**:
|
||
- **본 문서 = UI 코딩**: ① 설정 CRUD API(admin) ② Tab 18 = 설정 에디터 + 권장 SP 대시보드.
|
||
- **Phase II-분석(별도)**: θ 자동튜닝·PCT/차온·front-position·confidence 자동강등·느린 바이어스(= PhaseI §6 P-1~P-5). 본 문서 §6에 인터페이스 훅만.
|
||
|
||
---
|
||
|
||
## 0. 기존 UI 아키텍처 전제 (확인됨)
|
||
|
||
| 요소 | 사실 |
|
||
|:-----|:-----|
|
||
| 탭 라우터 | `core.js`의 `paneInit` 맵 + `activateTab(tab)` → `data-src`(`/panes/<tab>.html`) HTML을 fetch해 주입 후 `paneInit[tab]?.()` 호출 |
|
||
| 탭 등록 | ① `index.html` `<li class="nav-item" data-tab="ff">` ② `<section class="pane" id="pane-ff" data-src="/panes/ff.html">` ③ `<script src="/js/ff.js">` ④ `ff.js`에서 `paneInit.ff = ffInit` |
|
||
| API 헬퍼 | `core.js` `api(method, path, body)` (camelCase JSON), `esc()` XSS 이스케이프, `fmtVal/fmtTs` |
|
||
| admin 인증 | KB 토큰 — 헤더 `X-Kb-Token`, 백엔드 `IKbAuthService.ValidateAsync`, 프론트 `sessionStorage.kbToken` (docs.js 패턴) |
|
||
| JSON 바인딩 | `PropertyNamingPolicy=null` + **`PropertyNameCaseInsensitive=true`** → 프론트 camelCase 바디가 PascalCase DTO에 바인딩됨. 응답은 **camelCase 명시 익명객체**(CODING_CONVENTIONS) |
|
||
| 라이브 폴링 | trend.js의 `setInterval` 패턴 |
|
||
|
||
---
|
||
|
||
## 1. 파일 배치 (신규/변경)
|
||
|
||
```
|
||
변경:
|
||
src/Core/Application/Feedforward/IFeedforwardStores.cs # IFeedforwardConfigStore에 CRUD 추가
|
||
src/Infrastructure/Control/FeedforwardConfigStore.cs # Save/Delete (파라미터화)
|
||
src/Web/Controllers/FeedforwardController.cs # config CRUD(admin) 추가
|
||
src/Web/wwwroot/index.html # nav-item + pane + script 3줄
|
||
신규:
|
||
src/Web/wwwroot/panes/ff.html # 대시보드 + 설정 에디터 마크업
|
||
src/Web/wwwroot/js/ff.js # paneInit.ff — 폴링·렌더·에디터
|
||
src/Web/wwwroot/css/ff.css # (또는 style.css에 추가)
|
||
```
|
||
|
||
> Phase I과 동일하게 **단일 csproj**(`src/Web/ExperionCrawler.csproj`). C# 빌드: `dotnet build src/Web/ExperionCrawler.csproj`.
|
||
|
||
---
|
||
|
||
## 2. 백엔드 — 설정 CRUD (admin)
|
||
|
||
### 2.1 Store 인터페이스 확장 (`IFeedforwardStores.cs`)
|
||
|
||
```csharp
|
||
public interface IFeedforwardConfigStore
|
||
{
|
||
Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default);
|
||
Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default); // Id==0 INSERT, else UPDATE. 반환=id
|
||
Task DeleteColumnAsync(int columnId, CancellationToken ct = default);
|
||
}
|
||
```
|
||
|
||
### 2.2 Store 구현 추가 (`FeedforwardConfigStore.cs`)
|
||
|
||
> **★ 사용자 입력이 들어오므로 전 컬럼 파라미터 바인딩**(인젝션 차단). 컬럼+스트림은 트랜잭션으로 원자적 교체.
|
||
> `AdvisoryOnly`는 항상 TRUE 강제(불변식).
|
||
|
||
```csharp
|
||
// using System.Data.Common; 추가
|
||
|
||
private static DbParameter P(DbCommand cmd, string name, object? val)
|
||
{
|
||
var p = cmd.CreateParameter();
|
||
p.ParameterName = name;
|
||
p.Value = val ?? DBNull.Value;
|
||
cmd.Parameters.Add(p);
|
||
return p;
|
||
}
|
||
|
||
public async Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default)
|
||
{
|
||
var conn = _ctx.Database.GetDbConnection();
|
||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||
await using var tx = await conn.BeginTransactionAsync(ct);
|
||
|
||
int id = cfg.Id;
|
||
var levelTags = string.Join(',', cfg.LevelTags);
|
||
|
||
if (id == 0)
|
||
{
|
||
await using var cmd = conn.CreateCommand();
|
||
cmd.Transaction = tx;
|
||
cmd.CommandText = """
|
||
INSERT INTO ff_column_config
|
||
(name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
|
||
feed_filter_tau_sec, feed_move_thr_per_min, press_filter_tau_sec,
|
||
pressure_band, settle_sec, stale_sec, product_key, advisory_only)
|
||
VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,TRUE)
|
||
RETURNING id
|
||
""";
|
||
P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant());
|
||
P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); P(cmd,"@lvl",levelTags.ToLowerInvariant());
|
||
P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin);
|
||
P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand); P(cmd,"@settle",cfg.SettleSec);
|
||
P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
|
||
id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||
}
|
||
else
|
||
{
|
||
await using var cmd = conn.CreateCommand();
|
||
cmd.Transaction = tx;
|
||
cmd.CommandText = """
|
||
UPDATE ff_column_config SET
|
||
name=@name, enabled=@en, feed_tag=@feed, pressure_tag=@pres, level_tags=@lvl,
|
||
scan_sec=@scan, feed_filter_tau_sec=@fft, feed_move_thr_per_min=@fmt,
|
||
press_filter_tau_sec=@pft, pressure_band=@pb, settle_sec=@settle,
|
||
stale_sec=@stale, product_key=@pk, advisory_only=TRUE
|
||
WHERE id=@id
|
||
""";
|
||
P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled);
|
||
P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant());
|
||
P(cmd,"@lvl",levelTags.ToLowerInvariant()); P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec);
|
||
P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand);
|
||
P(cmd,"@settle",cfg.SettleSec); P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
|
||
await cmd.ExecuteNonQueryAsync(ct);
|
||
}
|
||
|
||
// 스트림 원자적 교체
|
||
await using (var del = conn.CreateCommand())
|
||
{
|
||
del.Transaction = tx; del.CommandText = "DELETE FROM ff_stream_config WHERE column_id=@id";
|
||
P(del,"@id",id); await del.ExecuteNonQueryAsync(ct);
|
||
}
|
||
foreach (var s in cfg.Streams)
|
||
{
|
||
await using var ins = conn.CreateCommand();
|
||
ins.Transaction = tx;
|
||
ins.CommandText = """
|
||
INSERT INTO ff_stream_config
|
||
(column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, tau_sec,
|
||
sp_min, sp_max, rate_up_per_min, rate_dn_per_min, reflux_from_product, grade)
|
||
VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade)
|
||
""";
|
||
P(ins,"@cid",id); P(ins,"@key",s.Key); P(ins,"@flow",s.FlowTag.ToLowerInvariant());
|
||
P(ins,"@role",s.Role.ToString()); P(ins,"@k",s.TargetCoeff); P(ins,"@tup",s.ThetaUpSec);
|
||
P(ins,"@tdn",s.ThetaDnSec); P(ins,"@tau",s.TauSec); P(ins,"@smin",s.SpMin); P(ins,"@smax",s.SpMax);
|
||
P(ins,"@rup",s.RateUpPerMin); P(ins,"@rdn",s.RateDnPerMin); P(ins,"@rfp",s.RefluxFromProduct);
|
||
P(ins,"@grade",s.Grade.ToString());
|
||
await ins.ExecuteNonQueryAsync(ct);
|
||
}
|
||
|
||
await tx.CommitAsync(ct);
|
||
return id;
|
||
}
|
||
|
||
public async Task DeleteColumnAsync(int columnId, CancellationToken ct = default)
|
||
{
|
||
var conn = _ctx.Database.GetDbConnection();
|
||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||
await using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "DELETE FROM ff_column_config WHERE id=@id"; // ON DELETE CASCADE → 스트림 동반 삭제
|
||
P(cmd,"@id",columnId);
|
||
await cmd.ExecuteNonQueryAsync(ct);
|
||
}
|
||
```
|
||
|
||
> **누적 상태 정리(PhaseI 진단 잔여 #2)**: 컬럼 삭제 시 `AdvisoryStore`/`Supervisor._states`도 정리하려면
|
||
> `IFeedforwardAdvisoryStore.Remove(int id)`를 추가하고 컨트롤러 DELETE에서 호출(아래 §2.3 주석). Supervisor는
|
||
> 다음 Tick에서 enabled 목록에 없으면 자연 미갱신 — `_states` 잔존만 남으나 미미.
|
||
|
||
### 2.3 컨트롤러 확장 (`FeedforwardController.cs`)
|
||
|
||
```csharp
|
||
// 생성자에 IKbAuthService 주입 (DocsController 패턴)
|
||
private readonly IFeedforwardAdvisoryStore _store;
|
||
private readonly IFeedforwardConfigStore _config;
|
||
private readonly IKbAuthService _auth;
|
||
public FeedforwardController(IFeedforwardAdvisoryStore store, IFeedforwardConfigStore config, IKbAuthService auth)
|
||
{ _store = store; _config = config; _auth = auth; }
|
||
|
||
private Task<bool> IsAdminAsync(CancellationToken ct)
|
||
=> _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
|
||
|
||
// ── 설정 조회 (admin) ──────────────────────────────────────────
|
||
[HttpGet("config")]
|
||
public async Task<IActionResult> GetConfig(CancellationToken ct)
|
||
{
|
||
if (!await IsAdminAsync(ct)) return Unauthorized();
|
||
var cols = await _config.LoadAllAsync(ct);
|
||
return Ok(new { columns = cols.Select(MapConfig) });
|
||
}
|
||
|
||
[HttpPost("config")]
|
||
public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct)
|
||
{
|
||
if (!await IsAdminAsync(ct)) return Unauthorized();
|
||
var id = await _config.SaveColumnAsync(body, ct);
|
||
return Ok(new { success = true, id });
|
||
}
|
||
|
||
[HttpDelete("config/{id:int}")]
|
||
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
|
||
{
|
||
if (!await IsAdminAsync(ct)) return Unauthorized();
|
||
await _config.DeleteColumnAsync(id, ct);
|
||
// _store.Remove(id); // IFeedforwardAdvisoryStore.Remove 구현 시 활성화
|
||
return Ok(new { success = true });
|
||
}
|
||
|
||
// camelCase 매핑 (설정 — 응답)
|
||
private static object MapConfig(ColumnConfig c) => new
|
||
{
|
||
id = c.Id, name = c.Name, enabled = c.Enabled, advisoryOnly = c.AdvisoryOnly,
|
||
feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags,
|
||
scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec,
|
||
feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec,
|
||
pressureBand = c.PressureBand, settleSec = c.SettleSec, staleSec = c.StaleSec, productKey = c.ProductKey,
|
||
streams = c.Streams.Select(s => new
|
||
{
|
||
key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), targetCoeff = s.TargetCoeff,
|
||
thetaUpSec = s.ThetaUpSec, thetaDnSec = s.ThetaDnSec, tauSec = s.TauSec,
|
||
spMin = s.SpMin, spMax = s.SpMax, rateUpPerMin = s.RateUpPerMin, rateDnPerMin = s.RateDnPerMin,
|
||
refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString()
|
||
})
|
||
};
|
||
```
|
||
|
||
> `[FromBody] ColumnConfig`는 record라도 `PropertyNameCaseInsensitive=true`로 camelCase 바디가 바인딩됨.
|
||
> `double.MaxValue`(1e9 DDL 기본) 같은 큰 수는 그대로 직렬화/역직렬화. `Enum.Parse`는 Role/Grade 문자열로 처리(StreamConfig가 enum이라 JSON 문자열 "Commanded"/"A" 그대로 바인딩).
|
||
|
||
---
|
||
|
||
## 3. 프론트엔드 — Tab 18
|
||
|
||
### 3.1 index.html 와이어링 (3곳)
|
||
|
||
```html
|
||
<!-- (1) nav: data-tab="trend" 다음 -->
|
||
<li class="nav-item" data-tab="ff">
|
||
<span class="nav-ico">⚖️</span><span class="nav-txt">유량 권장(FF)</span>
|
||
</li>
|
||
|
||
<!-- (2) pane: pane-trend 다음 -->
|
||
<section class="pane" id="pane-ff" data-src="/panes/ff.html"></section>
|
||
|
||
<!-- (3) script: /js/trend.js 다음 -->
|
||
<script src="/js/ff.js"></script>
|
||
<link rel="stylesheet" href="/css/ff.css"> <!-- 또는 style.css에 병합 -->
|
||
```
|
||
|
||
### 3.2 panes/ff.html
|
||
|
||
```html
|
||
<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>
|
||
|
||
<!-- 설정 에디터 (admin) -->
|
||
<div id="ff-cfg" class="ff-cfg" style="display:none">
|
||
<div class="ff-cfg-bar">
|
||
<input id="ff-token" type="password" placeholder="admin 토큰" class="inp">
|
||
<button id="ff-unlock" class="btn">잠금해제</button>
|
||
<button id="ff-new" class="btn" disabled>+ 컬럼</button>
|
||
<span id="ff-cfg-msg" class="ff-msg"></span>
|
||
</div>
|
||
<div id="ff-cfg-list"></div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### 3.3 js/ff.js (paneInit.ff)
|
||
|
||
```javascript
|
||
/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터.
|
||
대시보드는 공개 읽기(/api/ff/advisory), 설정은 admin(X-Kb-Token). */
|
||
paneInit.ff = ffInit;
|
||
|
||
let ffTimer = null;
|
||
|
||
function ffToken() { return sessionStorage.getItem('kbToken') || ''; }
|
||
async function ffApiAdmin(method, path, body) {
|
||
const h = { 'Content-Type': 'application/json' };
|
||
const t = ffToken(); if (t) h['X-Kb-Token'] = t;
|
||
const res = await fetch(path, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
|
||
if (res.status === 401) throw new Error('UNAUTH');
|
||
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-unlock').onclick = ffUnlock;
|
||
document.getElementById('ff-new').onclick = () => ffEditColumn(null);
|
||
if (ffToken()) ffEnableAdmin();
|
||
}
|
||
|
||
// ── 대시보드 (공개) ──────────────────────────────────────────────
|
||
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 => `
|
||
<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)}">${esc(s.role)}</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">D·B는 레벨 제어가 구동(기대치). 권장값은 참고 — 인가는 운전원.</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── 설정 에디터 (admin) ──────────────────────────────────────────
|
||
async function ffUnlock() {
|
||
const tok = document.getElementById('ff-token').value.trim();
|
||
if (!tok) return;
|
||
// KB 로그인 재사용: 토큰 유효성은 첫 admin 호출에서 검증
|
||
sessionStorage.setItem('kbToken', tok);
|
||
try { await ffLoadConfig(); ffEnableAdmin(); ffMsg('잠금 해제됨'); }
|
||
catch (e) { sessionStorage.removeItem('kbToken'); ffMsg('토큰 무효', true); }
|
||
}
|
||
function ffEnableAdmin() { document.getElementById('ff-new').disabled = false; ffLoadConfig().catch(()=>{}); }
|
||
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 ffApiAdmin('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 ffApiAdmin('DELETE', `/api/ff/config/${id}`); await ffLoadConfig(); ffMsg('삭제됨'); }
|
||
catch (e) { ffMsg(e.message==='UNAUTH'?'권한 없음':'삭제 실패', true); }
|
||
}
|
||
|
||
// 간단 JSON 편집(턴키 최소형). Phase II-b에서 폼 위젯화 권장.
|
||
function ffEditColumn(c) {
|
||
const tmpl = c || { name:'', enabled:false, feedTag:'', pressureTag:null, levelTags:[],
|
||
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5, pressFilterTauSec:60,
|
||
pressureBand:3, settleSec:1800, staleSec:120, productKey:'P',
|
||
streams:[{key:'P',flowTag:'',role:'Commanded',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,
|
||
tauSec:900,spMin:0,spMax:1e9,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A'}] };
|
||
const json = prompt('컬럼 설정(JSON) 편집:', JSON.stringify(tmpl));
|
||
if (!json) return;
|
||
let body; try { body = JSON.parse(json); } catch { return ffMsg('JSON 파싱 오류', true); }
|
||
if (c) body.id = c.id;
|
||
ffApiAdmin('POST', '/api/ff/config', body)
|
||
.then(() => { ffLoadConfig(); ffMsg('저장됨'); })
|
||
.catch(e => ffMsg(e.message==='UNAUTH'?'권한 없음':'저장 실패: '+e.message, true));
|
||
}
|
||
```
|
||
|
||
> 에디터는 **턴키 최소형(JSON prompt)** 으로 제공. 운전원용 폼 위젯(스트림 행 추가/삭제, 검증)은 **Phase II-b**에서
|
||
> 고도화 권장(본 문서 범위 밖). 대시보드는 완성형.
|
||
|
||
### 3.4 css/ff.css (요지)
|
||
|
||
```css
|
||
.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}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 검증 절차 (diagnosis-checklist.md 8단계)
|
||
|
||
### STEP 1~2 맥락·구조
|
||
- 레이어: Web 컨트롤러(읽기 공개 + 설정 admin), Infra 스토어(파라미터화 CRUD), 프론트 탭.
|
||
- 변경 파일 4 / 신규 3. 제어 레지스터 무관.
|
||
|
||
### STEP 3 코드 읽기
|
||
순서: IFeedforwardStores(인터페이스) → ConfigStore(Save/Delete) → Controller → ff.js → ff.html/css.
|
||
|
||
### STEP 4 호출계층 지도
|
||
```
|
||
[대시보드] 브라우저 setInterval(3s) → GET /api/ff/advisory (공개) → AdvisoryStore (read)
|
||
[설정] ff.js (X-Kb-Token) → GET/POST/DELETE /api/ff/config → IsAdminAsync 게이트
|
||
→ ConfigStore.Save/Delete (트랜잭션·파라미터화) → ff_* 테이블
|
||
─ 제어 SP/OP 쓰기 경로 없음 (WriteTagAsync/SetModeAsync 미참조) ─
|
||
```
|
||
|
||
### STEP 5 패턴 매칭 (자가 사전점검)
|
||
| 체크 | 상태 |
|
||
|:-----|:-----|
|
||
| **SQL 인젝션** | CRUD 전 컬럼 **파라미터 바인딩**(`P()` 헬퍼) — f-string/concat 없음 ✅ |
|
||
| **인증** | 모든 변경/설정조회 엔드포인트 `IsAdminAsync` 선검사, 401 반환 ✅ |
|
||
| **XSS** | 렌더는 `esc()` 경유, 숫자는 `fmtVal` ✅ |
|
||
| **폴링 누수** | `ffInit` 재진입 시 `clearInterval` 선행, host 없으면(탭 이탈) 타이머 정지 ✅ |
|
||
| **camelCase 응답** | `MapConfig`/`MapColumn` 명시 익명객체 ✅ |
|
||
| **제어 쓰기 0건** | Control/Controllers grep `WriteTagAsync|SetModeAsync` = 0 (불변식 유지) ★ |
|
||
| 트랜잭션 | Save는 컬럼+스트림 원자적(`BeginTransactionAsync`/`Commit`) ✅ |
|
||
| 커넥션 | EF 소유 — 열기만 보장, 닫지 않음(스코프 종료 시 정리) |
|
||
|
||
### STEP 6 교차검증 (Q1~Q4)
|
||
- 설정 조회를 admin으로? Q3: 엔지니어링 값이라 의도. (대시보드 읽기는 공개)
|
||
- JSON prompt 에디터: Q4 장애? 잘못된 JSON은 파싱 catch로 무해. 운영 편의는 Phase II-b 폼으로.
|
||
- 토큰 미검증 unlock: 토큰을 sessionStorage에 넣고 **첫 admin 호출에서 검증**(401 시 제거) — docs.js와 동일.
|
||
|
||
### STEP 7 심각도 가이드
|
||
- HIGH: 인젝션/인증우회/빌드실패(없어야 함).
|
||
- MED: 트랜잭션 누락(부분 저장), 폴링 중복.
|
||
- LOW: 에디터 UX, 미사용 CSS.
|
||
|
||
### STEP 8 보고서 양식 — 감독자 채움(파일:줄 인용).
|
||
|
||
---
|
||
|
||
## 5. 빌드/런타임 검증 (감독자 승인 후)
|
||
- `dotnet build src/Web/ExperionCrawler.csproj` 경고0/에러0.
|
||
- **제어 쓰기 불변식**: `grep -rn "WriteTagAsync\|SetModeAsync" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs` → 0건.
|
||
- admin 미인증 `POST /api/ff/config` → 401, 인증 후 저장→`GET /api/ff/config` 반영.
|
||
- 인젝션: `name`에 `'); DROP TABLE ff_stream_config;--` 넣어도 **리터럴 저장**(파라미터화) 확인.
|
||
- 브라우저: Tab 18 진입 → 대시보드 3초 폴링, 과도 배너/신뢰등급 색/레벨주석 표시, 다른 탭 이동 시 폴링 정지(타이머 누수 없음).
|
||
- camelCase: 응답 필드가 `recommendedSp`·`massBalanceState` 등으로 옴(undefined 없음).
|
||
|
||
---
|
||
|
||
## 6. Phase II-분석 훅 (별도 진행 — 본 UI 문서 범위 밖)
|
||
|
||
PhaseI §6 P-1~P-5(θ 자동튜닝·PCT/차온·front-position·confidence 자동강등·느린 바이어스)는
|
||
**분석 엔진 확장**이라 UI와 분리. 본 UI는 그 산출을 표시할 **자리만** 둔다:
|
||
- `StreamAdvisory.Grade`(이미 표시) ← confidence 자동강등(P-5) 연결점.
|
||
- 컬럼 카드 `ff-note`/배너 ← sweet-spot 드리프트 경고(P-3) 표시 위치.
|
||
- 설정에 `tempTags`·`analyzerTag`·`dTdP`·`pRef` 필드 추가 시 ColumnConfig 확장(P-2) — DDL ALTER + 로더/CRUD 컬럼 추가로 후속.
|
||
|
||
---
|
||
|
||
## 7. 턴키 상태 & 잔여
|
||
**턴키**: 백엔드 CRUD(파라미터화)·컨트롤러·프론트(대시보드 완성, 에디터 최소형)·index 와이어링·검증절차 모두 포함.
|
||
**구현 순서**: ① 인터페이스+ConfigStore CRUD → ② 컨트롤러(IKbAuthService 주입) → ③ index.html 3줄 + ff.html/ff.js/ff.css → ④ build → ⑤ admin 토큰으로 CRUD·인젝션·폴링 검증.
|
||
**잔여(판단/후속)**: 에디터 폼 위젯화(II-b), `AdvisoryStore.Remove`로 삭제 컬럼 정리, 분석 훅 P-1~P-5.
|
||
|
||
---
|
||
|
||
## 8. Phase I 회고 — 인증 제거 (2026-05-31 적용)
|
||
|
||
### 배경
|
||
Phase I 엔진은 **어디에도 Experion SP/OP 쓰기 코드가 없다**(`WriteTagAsync`/`SetModeAsync` 0건).
|
||
그런데 FF 설정 CRUD API에 `IKbAuthService`(KB admin 토큰) 인증이 붙어 있어,
|
||
운전원이 대시보드를 보기 위해 RAG 관리 탭 로그인이 필요하거나 별도 토큰을 입력해야 했다.
|
||
|
||
advisory(보조지표)는 늘상 운전원이 봐야 하는 페이지인데, 보지 못하게 막는 진입장벽이 불합리했다.
|
||
|
||
### 적용 변경
|
||
|
||
| 레이어 | 변경 내용 |
|
||
|:-------|:----------|
|
||
| `FeedforwardController.cs` | `IKbAuthService` 의존성 및 `IsAdminAsync()` 가드 제거. config CRUD 엔드포인트 인증 없이 동작 |
|
||
| `ff.js` | `ffApiAdmin`(토큰 헤더) → `ffApi`(인증 없음). `ffUnlock`/`ffEnableAdmin` 제거. `ffInit`에서 바로 설정 로드 |
|
||
| `ff.html` | `#ff-token` input + `#ff-unlock` 버튼 제거. `#ff-new` disabled 해제 |
|
||
|
||
### 향후 재도입 시점 (Phase III)
|
||
RSP 쓰기(Experion SP/OP write)가 실제로 구현되는 **Phase III**에서 `IKbAuthService`를 다시 주입하고
|
||
프론트에 토큰 입력 UI를 복원한다. 그때는 쓰기 동작이 있으므로 인증이 정당화된다.
|