Phase II:
- FfOperatorAction entity + ff_operator_action DDL/DbSet
- IFeedforwardWriteGuard + FeedforwardWriteGuard (SP bounds, grade C, transient, NaN)
- IFeedforwardAuditService + FeedforwardAuditService (raw ADO insert/query)
- FeedforwardSupervisor.AutoWriteAsync (per-stream OPC UA after Tick, rate-limited)
- FeedforwardConfigStore: advisory_only now read/writes DB, sp_node_id column
- FeedforwardController: auth (X-Kb-Token) on config/delete/write/audit;
POST write/{id}/{key} manual SP write; GET audit; write results in MapColumn
- ff.js: token header, auto-write badge, per-stream write result, spNodeId, advisoryOnly
- ff.css: .ff-write-badge, .ff-write, .ff-write-err, .ff-wg-blocked
- Program.cs: register audit (Scoped) + write guard (Singleton)
WO-2~7 (build 0W/0E, test 22/22):
- PCT monitor, θ auto-tune, slow bias, front position indicator,
total reflux recovery, config form expansion
278 lines
17 KiB
Markdown
278 lines
17 KiB
Markdown
# WO-7 (설정 편집 폼 확장 — 신규 필드 운전원 노출) — 완전코드 작업지시서
|
|
|
|
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**.
|
|
> **선행 완료 전제(검증됨)**: §0 + WO-1~6 전부 머지 완료. 백엔드(`ColumnConfig`/`StreamConfig` 신규 필드, `ff_*` DDL,
|
|
> ConfigStore Save/Load, Controller `MapConfig`)는 **이미 신규 필드를 저장·반환**한다. **본 WO는 프론트 폼(ff.js)만** 손댄다.
|
|
> **불변식**: 쓰기 0건(설정 저장은 advisory config일 뿐). C# 코드 변경 없음.
|
|
|
|
## 배경 / 목적
|
|
현재 설정 모달(`ffEditColumn`)은 §0 이전의 기본 필드(Feed/압력/Scan/필터/스트림 K·θ·τ…)만 폼에 노출한다.
|
|
WO-2~6에서 추가된 **온도/PCT·θ자동튜닝·느린바이어스·전환류 트리거/설정·스트림 환류/복귀SP** 필드는
|
|
**API로는 저장/조회되지만 폼에 입력칸이 없어** 운전원이 화면에서 못 바꾼다(저장 시 `undefined`→백엔드 기본값 유지).
|
|
특히 운전원 질문 "**균형 심각붕괴 트리거를 수정할 수 있나?**" → 현재 폼엔 없음. **본 WO로 노출**한다.
|
|
|
|
> **검증 사실**: `GET /api/ff/config`는 `tempTags, sensitiveTrayTag, dtdp, pRef, steamOpTag, thetaAutoTune, biasMaWindowSec,
|
|
> recoveryEnabled, recoveryAutoArm, imbalanceTriggerFrac, imbalanceTriggerSec, recoverySettleSec, returnRampSec,
|
|
> feedRecoverySp, deltaPTag, deltaPFloodLimit`(컬럼) + `isReflux, recoverySp`(스트림)을 이미 반환한다(Controller MapConfig).
|
|
> 따라서 **기존 컬럼 편집 시**엔 `{...c}`로 값이 이미 들어오고, **새 컬럼**만 default 보강이 필요하다.
|
|
|
|
## 변경 파일 (총 2개)
|
|
1. `src/Web/wwwroot/js/ff.js` — `def`(새컬럼 기본값) + `colHtml`(입력칸) + `ffStreamRow`(스트림 2칸) + `ffSaveForm`(저장)
|
|
2. `src/Web/wwwroot/css/ff.css` — 트리거 강조 스타일(선택)
|
|
|
|
---
|
|
|
|
## STEP 1 — 새 컬럼 기본값(`def`)에 신규 필드 추가
|
|
|
|
**파일**: `src/Web/wwwroot/js/ff.js`
|
|
**위치**: `ffEditColumn` 함수의 `const def = isNew ? {...} : {...}`
|
|
|
|
**찾기**:
|
|
```javascript
|
|
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||'' };
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
const def = isNew
|
|
? { name:'', enabled:false, feedTag:'', pressureTag:'',
|
|
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5,
|
|
pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P',
|
|
// WO-2 온도/PCT · WO-3 θ자동튜닝 · WO-4 바이어스
|
|
tempTags:[], sensitiveTrayTag:'', dtdp:0, pRef:null, steamOpTag:'', thetaAutoTune:false, biasMaWindowSec:21600,
|
|
// WO-6 전환류 복귀
|
|
recoveryEnabled:false, recoveryAutoArm:false, imbalanceTriggerFrac:0.10, imbalanceTriggerSec:600,
|
|
recoverySettleSec:1800, returnRampSec:600, feedRecoverySp:0, deltaPTag:'', deltaPFloodLimit:1e9,
|
|
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',isReflux:false,recoverySp:0},
|
|
{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',isReflux:true,recoverySp:null},
|
|
{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',isReflux:false,recoverySp:0},
|
|
{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',isReflux:false,recoverySp:0}
|
|
] }
|
|
: { ...c, pressureTag: c.pressureTag||'',
|
|
tempTags: c.tempTags||[], sensitiveTrayTag: c.sensitiveTrayTag||'', steamOpTag: c.steamOpTag||'', deltaPTag: c.deltaPTag||'' };
|
|
```
|
|
> 기존 컬럼은 `{...c}`로 숫자/불리언 신규 필드가 이미 들어온다. 위 추가 라인은 **null일 수 있는 문자열/배열 필드만** 빈값 정규화(입력칸에 `undefined`/`null` 표시 방지). `tempTags`는 배열이므로 폼에선 콤마 문자열로 변환해 보여준다(STEP 2).
|
|
|
|
---
|
|
|
|
## STEP 2 — 입력칸(`colHtml`)에 신규 섹션 2개 추가
|
|
|
|
**파일**: `src/Web/wwwroot/js/ff.js`
|
|
|
|
**찾기** (colHtml의 두번째 `.ff-modal-col` 닫는 부분 + 백틱 종료):
|
|
```javascript
|
|
<label><span class="ff-desc">Stale(초): 데이터 유효시간 — 마지막 갱신 후 이 시간 초과 시 사용 안 함</span><input class="inp" type="number" id="ff-f-staleSec" value="${def.staleSec}"></label>
|
|
</div>`;
|
|
```
|
|
|
|
**바꾸기** (기존 2칸 뒤에 온도/θ·전환류 2칸을 추가):
|
|
```javascript
|
|
<label><span class="ff-desc">Stale(초): 데이터 유효시간 — 마지막 갱신 후 이 시간 초과 시 사용 안 함</span><input class="inp" type="number" id="ff-f-staleSec" value="${def.staleSec}"></label>
|
|
</div>
|
|
<div class="ff-modal-col">
|
|
<div class="ff-modal-subhd">온도 프로파일 / θ 자동튜닝 <small>(WO-2·3·4)</small></div>
|
|
<label><span class="ff-desc">온도 태그(콤마구분, 상→하): 프로파일 PCT 모니터 대상. 비우면 온도기능 off</span><input class="inp" id="ff-f-tempTags" value="${esc((def.tempTags||[]).join(','))}"></label>
|
|
<label><span class="ff-desc">감도트레이 태그: 프론트(sweet-spot) 위치 지표. 비우면 상-하 차온 사용</span><input class="inp" id="ff-f-sensitiveTrayTag" value="${esc(def.sensitiveTrayTag||'')}"></label>
|
|
<label><span class="ff-desc">dT/dP(°C/압력): 압력보정온도(PCT) 계수. 0이면 생온도 사용</span><input class="inp" type="number" step="any" id="ff-f-dtdp" value="${def.dtdp}"></label>
|
|
<label><span class="ff-desc">P_ref(압력 기준점): 비우면 최초 정상압력으로 자동 시드</span><input class="inp" type="number" step="any" id="ff-f-pRef" value="${def.pRef==null?'':def.pRef}"></label>
|
|
<label><span class="ff-desc">스팀 OP 태그(예 tica-6111a.op): θ 추정 폐루프 오염 제거용</span><input class="inp" id="ff-f-steamOpTag" value="${esc(def.steamOpTag||'')}"></label>
|
|
<label><input type="checkbox" id="ff-f-thetaAutoTune" ${def.thetaAutoTune?'checked':''}> θ 자동튜닝(제안만, 자동반영 없음)</label>
|
|
<label><span class="ff-desc">바이어스 MA 창(초): K_obs·V_loss 장기평균 창(기본 6h=21600)</span><input class="inp" type="number" id="ff-f-biasMaWindowSec" value="${def.biasMaWindowSec}"></label>
|
|
</div>
|
|
<div class="ff-modal-col ff-recovery-col">
|
|
<div class="ff-modal-subhd">전환류 평형복귀 (WO-6) ★</div>
|
|
<label><input type="checkbox" id="ff-f-recoveryEnabled" ${def.recoveryEnabled?'checked':''}> 전환류 복귀 기능 사용</label>
|
|
<label><input type="checkbox" id="ff-f-recoveryAutoArm" ${def.recoveryAutoArm?'checked':''}> 자동 무장(체크 해제 시 운전원 ARM 필요)</label>
|
|
<label><span class="ff-desc">불균형 트리거 비율: |V_loss(MA)|/Feed 가 이 값 초과 지속 시 전환류 권장 (0.10 = 10%)</span><input class="inp ff-trig" type="number" step="any" id="ff-f-imbalanceTriggerFrac" value="${def.imbalanceTriggerFrac}"></label>
|
|
<label><span class="ff-desc">트리거 지속(초): 불균형이 이 시간 연속 지속돼야 발동(오발동 방지, 기본 600=10분)</span><input class="inp ff-trig" type="number" id="ff-f-imbalanceTriggerSec" value="${def.imbalanceTriggerSec}"></label>
|
|
<label><span class="ff-desc">평형 대기(초): 전환류 중 평형 회복 연속 만족 시간(기본 1800=30분)</span><input class="inp" type="number" id="ff-f-recoverySettleSec" value="${def.recoverySettleSec}"></label>
|
|
<label><span class="ff-desc">복귀 램프(초): 정상 복귀 시 드로우/피드 점진 복원 시간(기본 600)</span><input class="inp" type="number" id="ff-f-returnRampSec" value="${def.returnRampSec}"></label>
|
|
<label><span class="ff-desc">전환류 중 Feed 권장값: 보통 0(차단)</span><input class="inp" type="number" step="any" id="ff-f-feedRecoverySp" value="${def.feedRecoverySp}"></label>
|
|
<label><span class="ff-desc">차압(ΔP) 태그: 플러딩 트리거용(선택). 비우면 미사용</span><input class="inp" id="ff-f-deltaPTag" value="${esc(def.deltaPTag||'')}"></label>
|
|
<label><span class="ff-desc">ΔP 플러딩 상한: 초과 지속 시 전환류 트리거. 미사용 시 매우 큰 값</span><input class="inp" type="number" step="any" id="ff-f-deltaPFloodLimit" value="${def.deltaPFloodLimit}"></label>
|
|
</div>`;
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 3 — 스트림 행(`ffStreamRow`)에 환류/복귀SP 2칸 추가
|
|
|
|
### 3.1 스트림 테이블 헤더에 2칸 추가
|
|
|
|
**파일**: `src/Web/wwwroot/js/ff.js`
|
|
|
|
**찾기**:
|
|
```javascript
|
|
<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>
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
<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 title="전환류 시 전량환류 대상">전환류R</th><th title="전환류 시 이 스트림 권장값(비우면 0)">복귀SP</th><th>신뢰</th><th></th>
|
|
```
|
|
|
|
### 3.2 `ffStreamRow`의 `<tr>`에 입력칸 2개 추가
|
|
|
|
**찾기**:
|
|
```javascript
|
|
<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>
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
<td><input type="checkbox" ${s.refluxFromProduct?'checked':''} data-idx="${i}" data-f="refluxFromProduct"></td>
|
|
<td><input type="checkbox" ${s.isReflux?'checked':''} data-idx="${i}" data-f="isReflux"></td>
|
|
<td><input class="inp ff-si" type="number" step="any" value="${s.recoverySp==null?'':s.recoverySp}" data-idx="${i}" data-f="recoverySp" placeholder="0"></td>
|
|
<td><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></td>
|
|
```
|
|
|
|
### 3.3 스트림 추가 버튼 기본값에도 신규 필드
|
|
|
|
> `ff-stream-add` 클릭 시 새 행 객체에 신규 필드 없으면 체크박스/값이 깨질 수 있다.
|
|
|
|
**찾기**:
|
|
```javascript
|
|
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));
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
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',isReflux:false,recoverySp:null
|
|
}, i));
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 4 — 저장(`ffSaveForm`)에서 신규 필드 읽기
|
|
|
|
**파일**: `src/Web/wwwroot/js/ff.js`
|
|
|
|
### 4.1 컬럼 레벨 필드 추가
|
|
|
|
**찾기**:
|
|
```javascript
|
|
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 => {
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
staleSec: +g('ff-f-staleSec').value,
|
|
productKey: g('ff-f-productKey').value,
|
|
advisoryOnly: true,
|
|
// WO-2/3/4
|
|
tempTags: g('ff-f-tempTags').value.split(',').map(s=>s.trim()).filter(Boolean),
|
|
sensitiveTrayTag: g('ff-f-sensitiveTrayTag').value || null,
|
|
dtdp: +g('ff-f-dtdp').value,
|
|
pRef: g('ff-f-pRef').value === '' ? null : +g('ff-f-pRef').value,
|
|
steamOpTag: g('ff-f-steamOpTag').value || null,
|
|
thetaAutoTune: g('ff-f-thetaAutoTune').checked,
|
|
biasMaWindowSec: +g('ff-f-biasMaWindowSec').value,
|
|
// WO-6
|
|
recoveryEnabled: g('ff-f-recoveryEnabled').checked,
|
|
recoveryAutoArm: g('ff-f-recoveryAutoArm').checked,
|
|
imbalanceTriggerFrac: +g('ff-f-imbalanceTriggerFrac').value,
|
|
imbalanceTriggerSec: +g('ff-f-imbalanceTriggerSec').value,
|
|
recoverySettleSec: +g('ff-f-recoverySettleSec').value,
|
|
returnRampSec: +g('ff-f-returnRampSec').value,
|
|
feedRecoverySp: +g('ff-f-feedRecoverySp').value,
|
|
deltaPTag: g('ff-f-deltaPTag').value || null,
|
|
deltaPFloodLimit: +g('ff-f-deltaPFloodLimit').value,
|
|
streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => {
|
|
```
|
|
|
|
### 4.2 스트림 레벨 필드 추가
|
|
|
|
**찾기**:
|
|
```javascript
|
|
rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'),
|
|
refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade')
|
|
};
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'),
|
|
refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade'),
|
|
isReflux: v(null,'isReflux'),
|
|
recoverySp: (() => { const x = v(null,'recoverySp'); return x === '' ? null : +x; })()
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 5 — `ff.css` (선택, 트리거 강조)
|
|
|
|
**파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가:
|
|
```css
|
|
/* WO-7 설정폼 신규 섹션 */
|
|
.ff-modal-subhd{font-weight:600;margin:4px 0 6px;color:var(--t1);border-bottom:1px solid var(--bd);padding-bottom:3px}
|
|
.ff-modal-subhd small{font-weight:400;color:var(--t2)}
|
|
.ff-recovery-col{background:rgba(90,0,0,.08);border-radius:6px;padding:6px}
|
|
.ff-trig{border-color:#ff8a80 !important}
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 6 — 검증
|
|
|
|
```bash
|
|
# 1) JS 문법
|
|
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
|
|
# 2) C# 미변경 확인(이 WO는 프론트 전용) — 빌드는 영향 없음(원하면)
|
|
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Error"
|
|
```
|
|
|
|
**기대**: `JS OK`. (C# 변경 없음 → 빌드 영향 없음.)
|
|
|
|
### 런타임 확인(브라우저)
|
|
1. `Ctrl+F5`로 캐시 무효화 후 Tab "유량 권장(FF)" → "설정 ▾" → 기존 컬럼 "편집" 또는 "+ 컬럼".
|
|
2. 모달에 **온도/θ 칸**과 **전환류 평형복귀 칸**(붉은 박스)이 보인다.
|
|
3. **트리거 수정 확인**(운전원 질문 대응): "불균형 트리거 비율"=0.15, "트리거 지속(초)"=300 으로 바꿔 저장 →
|
|
다시 "편집" 열어 값이 유지되는지 확인(= API 저장·재로드 라운드트립). → **운전원이 트리거를 직접 수정 가능**.
|
|
4. 스트림 표에 "전환류R"(체크) / "복귀SP" 칸이 보이고 저장·재로드 유지.
|
|
|
|
---
|
|
|
|
## 감독자 Sign-off
|
|
| 항목 | 상태 | 서명 |
|
|
|:--|:--:|:--:|
|
|
| 새 컬럼 def에 신규 필드 기본값(undefined 표시 없음) | ✅ | windpacer 2026-05-31 |
|
|
| 온도/θ 섹션 입력칸 노출 | ✅ | windpacer 2026-05-31 |
|
|
| 전환류 트리거(비율·지속) 입력칸 노출 + 저장·재로드 유지 | ✅ | windpacer 2026-05-31 |
|
|
| 스트림 전환류R·복귀SP 칸 노출 | ✅ | windpacer 2026-05-31 |
|
|
| tempTags 콤마↔배열 변환, pRef/recoverySp 빈값→null | ✅ | windpacer 2026-05-31 |
|
|
| node -c 통과 | ✅ | windpacer 2026-05-31 |
|
|
|
|
## 주의(약한 LLM 함정)
|
|
1. **C# 손대지 말 것** — 백엔드는 이미 신규 필드 저장/반환. 본 WO는 ff.js(+css)만.
|
|
2. **tempTags는 배열↔콤마문자열** — 표시는 `join(',')`, 저장은 `split(',')...filter(Boolean)`.
|
|
3. **빈값→null 매핑** — `pRef`/`recoverySp`는 빈 문자열이면 null(백엔드가 NaN/NULL 시드 처리). 0과 빈값을 혼동 말 것.
|
|
4. **체크박스는 `.checked`** — `v(null,'isReflux')`는 기존 `v` 헬퍼가 checkbox면 `el.checked`(불리언) 반환하므로 그대로 사용.
|
|
5. **스트림 칸 추가는 헤더와 행 둘 다** — `<th>` 2개와 `<td>` 2개 개수 일치(안 맞으면 표 깨짐).
|
|
6. **스트림 add 버튼 기본객체에도** isReflux/recoverySp 추가(STEP 3.3) — 빠뜨리면 새 행 체크박스 깨짐.
|
|
</content>
|