# HC900 Crawler — 탭 수정 작업지시서 (교정판)

> 본 문서는 실측 검증(DB 직접 조회 + 실행 중인 앱 API Content-Type 확인)으로 교정되었다.
> 이전 판의 "문제 1(DB 스키마 불일치)"과 "문제 2(DI 캐스트 오류)"는 **오진으로 확인되어 삭제**했다.

---

## 진단 요약

빌드: ✅ 성공 / 앱: ✅ 실행 중(포트 5000) / DB 스키마: ✅ 정상

**진짜 근본 원인은 단 하나: JS가 존재하지 않는 API 경로를 호출한다.**

ASP.NET이 `MapFallbackToFile("index.html")`로 매핑되어 있어, 없는 GET 경로는
**404가 아니라 200 + `index.html`(HTML)** 을 반환한다.
프런트는 이 HTML을 `JSON.parse()` 시도 → 예외 → 사용자에게는 "DB/연결 오류"처럼 보인다.
실제로는 DB도 연결도 정상이며, **경로 오타/미구현**이 전부다.

### 검증 방법 (재현 가능)
```bash
# text/html = 엔드포인트 없음(SPA 폴백),  application/json = 실제 존재
curl -s -o /dev/null -w "%{content_type}\n" http://localhost:5000/api/history/tags      # application/json ✓
curl -s -o /dev/null -w "%{content_type}\n" http://localhost:5000/api/history/tagnames  # text/html  ✗(없음)
```

### 실측 결과 (오진 정정 포함)

| 이전 판 주장 | 실측 | 판정 |
|---|---|---|
| controller_id 컬럼 미적용 → InitializeAsync 실패 | 5개 테이블 모두 컬럼 존재, v_tag_summary 정상 | **오진(삭제)** |
| IHc900GatewayService DI 캐스트 오류 | 해당 등록 줄 이미 제거됨, 빌드 성공 | **오진(삭제)** |
| /api/realtime/points DB오류 | 200 application/json 정상 | **오진** |
| JS 경로 불일치 (hist/evt/write/fast/t2s) | text/html 또는 404 확인 | **정확(유지)** |

---

## 별도 이슈: 데이터가 비어 보이는 진짜 원인 (코드 아님)

경로를 다 고쳐도 **표에 값이 안 나올 수 있다.** 원인은 게이트웨이 Modbus 연결 실패다.

```
GET /api/gateway/health →
  { "status":"NotServing", "controllerIp":"192.168.0.230", "pollCount":0 }
realtime_table rows = 0
```

게이트웨이가 `192.168.0.230`에 연결 못 함(`NotServing`) → 수집 0건 → 모든 탭 데이터 공란.
HC900 기본 IP는 `192.168.0.240`. **Setup 탭에서 컨트롤러 IP를 실제 값으로 교정 후 재시작.**
(이건 코드 수정과 무관한 운영 설정 문제이며, 탭 로직 수정과 병행해야 데이터가 보인다.)

---

## 수정 대상 (모두 JS 경로 문제, DB 무관)

| # | 탭 | JS 호출 (없는 경로) | 실제/대체 경로 | 작업 |
|---|---|---|---|---|
| 1 | hist | `GET /api/history/tagnames` | `GET /api/history/tags` | JS 1줄 |
| 2 | hist | `GET /api/history/query` | `POST /api/history/query` | JS 메서드+body |
| 3 | hist | `/api/experion/hypertable/*` | 없음 | 엔드포인트 신설 |
| 4 | evt | `/api/event-history/*` (3개) | `POST /api/events/query` | JS 3함수 |
| 5 | write | `/api/points/*` (4개) | `POST /api/gateway/write` | JS 4함수 |
| 6 | fast | `/api/fast/*`, `/api/pointbuilder/points` | FastController 없음 | 컨트롤러 신설 |
| 7 | t2s | `POST /api/text-to-sql/execute` | `execute-mcp`만 존재 | JS 통일 또는 신설 |

> pid / llmchat / kbadmin / docs / trend / ff 탭은 **코드 수정 불필요.**
> 데이터 공란은 위 "게이트웨이 Modbus 연결" 이슈로만 설명된다.

---

## 수정 1: `hist.js` — 이력 조회 탭

### 1-1. tagnames → tags (hist.js:42)
```javascript
// 변경 전
const d = await api('GET', '/api/history/tagnames');   // text/html (없음)
// 변경 후
const d = await api('GET', '/api/history/tags');
```

### 1-2. query GET → POST (hist.js:98)
`HistoryController`는 `[HttpPost("query")]`만 있다(GET 없음). GET 호출은 SPA 폴백으로 빠진다.

```javascript
// 변경 전 (hist.js:98) — 쿼리스트링 GET
const d = await api('GET', `/api/history/query?${params}`);

// 변경 후 — POST + body DTO
const d = await api('POST', '/api/history/query', {
    tagNames: tags,                       // 배열
    from: fromDt,                         // ISO 또는 null
    to:   toDt,
    limit: parseInt(limit) || 1000
});
```
> 응답 형태(`d.rows`, `d.tagNames`)는 기존 코드와 동일하므로 파싱부는 그대로 둔다.

### 1-3. hypertable 엔드포인트 신설 (hist.js:241, 343)
JS가 `/api/experion/hypertable/status`·`/create`를 호출하나 둘 다 없다.

`Hc900Controllers.cs`에 컨트롤러 추가:
```csharp
[ApiController]
[Route("api/experion/hypertable")]
public class HypertableController : ControllerBase
{
    private readonly IExperionDbService _db;
    public HypertableController(IExperionDbService db) => _db = db;

    [HttpGet("status")]
    public async Task<IActionResult> Status()
        => Ok(await _db.GetHypertableStatsAsync());   // 기존 hypertable 조회 메서드 재사용

    [HttpPost("create")]
    public IActionResult Create()
        => Ok(new { success = true, message = "history_table 하이퍼테이블 설정됨" });
}
```
> `IExperionDbService`에 하이퍼테이블 상태 조회 메서드가 이미 있으면 그대로,
> 없으면 `Hc900DbContext.cs`의 `timescaledb_information.hypertables` 조회 로직(이미 존재)을
> public 메서드로 노출.

---

## 수정 2: `evt.js` — 이벤트 히스토리 탭

`/api/event-history/*` 3개 경로 모두 없음. 실제 API는 `POST /api/events/query`(정상 작동).
**백엔드 변경 없이 JS만 수정.**

```javascript
// (1) 디지털 태그 목록 — 전용 API 없음 → realtime에서 클라이언트 필터
//     주의: 응답은 PascalCase (TagName, LiveValue)
async function evtLoadDigitalTags() {
    const pts = await api('GET', '/api/realtime/points');
    return pts.filter(p => (p.LiveValue ?? '').startsWith('{'));   // 상태 레이블 형식 {n|LABEL|}
}

// (2) 이벤트 조회 — GET /api/event-history → POST /api/events/query
async function evtQuery() {
    const body = {
        tagName:   document.getElementById('ev-tag').value.trim()  || null,
        eventType: document.getElementById('ev-type').value         || null,
        area:      document.getElementById('ev-area').value.trim()  || null,
        from:      fromISO,
        to:        toISO,
        limit:     500
    };
    const d = await api('POST', '/api/events/query', body);
    // d = EventHistoryRecord[] (PascalCase: TagName, EventType, EventTime, CurrValue ...)
}

// (3) 요약 — 전용 엔드포인트 없음 → (2) 결과로 클라이언트 집계
//     예: eventType별 count, 또는 최근 N건 그룹화
```

---

## 수정 3: `write.js` — 태그 쓰기 탭

`/api/points/*` 4개 모두 없음. HC900 쓰기는 gRPC 기반 `POST /api/gateway/write` 단일 경로.
`WriteTagDto = { ControllerId, TagName, Value }`.

```javascript
// write/mode/control 전부 동일 엔드포인트로 통합
async function writeValue() {
    const controllerId = document.getElementById('w-ctrl').value || 'HC1';
    const tagName = document.getElementById('w-tag').value.trim();
    const value   = parseFloat(document.getElementById('w-val').value);
    const d = await api('POST', '/api/gateway/write', { controllerId, tagName, value });
}

// MODE 변경: tagName에 .MODE 접미사, value=정수
async function writeMode() {
    const controllerId = document.getElementById('w-ctrl').value || 'HC1';
    const tagName = document.getElementById('m-tag').value.trim() + '.MODE';
    const value   = parseInt(document.getElementById('m-mode').value);
    await api('POST', '/api/gateway/write', { controllerId, tagName, value });
}

// read: 전용 read API 없음 → realtime에서 조회 (PascalCase 주의!)
async function readTag() {
    const tagName = document.getElementById('r-tag').value.trim().toLowerCase();
    const pts = await api('GET', '/api/realtime/points');
    const point = pts.find(p => (p.TagName ?? '').toLowerCase() === tagName);  // ← p.TagName (대문자)
    // point.LiveValue 표시
}
```
> ⚠️ 이전 판은 `p.tagName`(소문자)으로 적었으나 직렬화가 PascalCase라 **항상 undefined**가 된다.
> 반드시 `p.TagName`, `p.LiveValue`로 쓴다.

**컨트롤러 드롭다운** (`panes/write.html` + `write.js`):
```html
<div class="fg"><label>컨트롤러</label><select id="w-ctrl" class="inp"></select></div>
```
```javascript
async function writeLoadControllers() {
    const d = await api('GET', '/api/gateway/status');  // { controllers: [{controllerId, controllerIp}, ...] }
    const sel = document.getElementById('w-ctrl');
    (d.controllers || []).forEach(c => {
        const id = c.controllerId ?? c.ControllerId;
        const opt = document.createElement('option');
        opt.value = id; opt.textContent = `${id} — ${c.controllerIp ?? c.ControllerIp}`;
        sel.appendChild(opt);
    });
}
```
> `/api/gateway/status`의 `controllers` 필드는 `Hc900RealtimeService.ControllerConnected`
> 직렬화 결과이므로 키 케이싱을 실제 응답으로 한 번 확인하고 맞춘다.

---

## 수정 4: `fast.js` — fastRecord 탭 (컨트롤러 신규)

`IExperionDbService`에 FastSession/FastRecord 메서드 존재(`IExperionServices.cs:44-61`).
`GetRealtimeRecordsByTagNamesAsync`(L61)도 있어 실시간 값 채우기 가능. **컨트롤러만 신설.**

### 신규 파일: `src/Hc900Crawler/Controllers/FastController.cs`
```csharp
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Core.Domain.Entities;
using Microsoft.AspNetCore.Mvc;

namespace Hc900Crawler.Web.Controllers;

[ApiController]
[Route("api/fast")]
public class FastController : ControllerBase
{
    private readonly IExperionDbService _db;
    private static readonly Dictionary<int, CancellationTokenSource> _sessions = new();

    public FastController(IExperionDbService db) => _db = db;

    [HttpGet("sessions")]
    public async Task<IActionResult> GetSessions() => Ok(await _db.GetFastSessionsAsync());

    [HttpGet("{id}")]
    public async Task<IActionResult> GetSession(int id)
    {
        var s = await _db.GetFastSessionAsync(id);
        return s == null ? NotFound() : Ok(s);
    }

    [HttpPost("start")]
    public async Task<IActionResult> Start([FromBody] FastSessionStartRequest req)
    {
        var createReq = new FastSessionCreateRequest(
            req.Name, DateTime.UtcNow, "Running",
            req.SamplingMs, req.DurationSec,
            System.Text.Json.JsonSerializer.Serialize(req.TagNames),
            0, req.RetentionDays);
        var session = await _db.CreateFastSessionAsync(createReq);

        var cts = new CancellationTokenSource();
        lock (_sessions) { _sessions[session.Id] = cts; }
        _ = Task.Run(() => RunSessionAsync(session.Id, req, cts.Token));
        return Ok(new { session.Id, status = "Running" });
    }

    private async Task RunSessionAsync(int sessionId, FastSessionStartRequest req, CancellationToken ct)
    {
        var endAt = DateTime.UtcNow.AddSeconds(req.DurationSec);
        int rowCount = 0;
        try
        {
            while (!ct.IsCancellationRequested && DateTime.UtcNow < endAt)
            {
                var now = DateTime.UtcNow;
                // 실시간 값 조회 후 기록 (빈 값 대신 실제 값)
                var live = (await _db.GetRealtimeRecordsByTagNamesAsync(req.TagNames))
                           .ToDictionary(p => p.TagName, p => p.LiveValue);
                var records = req.TagNames.Select(t => new FastRecord
                {
                    SessionId  = sessionId,
                    RecordedAt = now,
                    TagName    = t,
                    Value      = live.GetValueOrDefault(t)
                }).ToList();

                await _db.BatchInsertFastRecordsAsync(records);
                rowCount += records.Count;
                await _db.UpdateFastSessionRowCountAsync(sessionId, rowCount);
                await Task.Delay(req.SamplingMs, ct);
            }
            await _db.UpdateFastSessionStatusAsync(sessionId, "Completed");
        }
        catch (OperationCanceledException)
        {
            await _db.UpdateFastSessionStatusAsync(sessionId, "Stopped");
        }
        finally { lock (_sessions) { _sessions.Remove(sessionId); } }
    }

    [HttpPost("{id}/stop")]
    public async Task<IActionResult> Stop(int id)
    {
        lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
        await _db.UpdateFastSessionStatusAsync(id, "Stopped");
        return Ok(new { success = true });
    }

    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
        await _db.DeleteFastSessionAsync(id);
        return Ok(new { success = true });
    }

    [HttpPost("{id}/pin")]
    public async Task<IActionResult> Pin(int id, [FromBody] bool pinned)
    {
        await _db.UpdateFastSessionPinnedAsync(id, pinned);
        return Ok(new { success = true });
    }

    [HttpGet("{id}/records")]
    public async Task<IActionResult> GetRecords(int id, [FromQuery] DateTime? from = null, [FromQuery] DateTime? to = null)
        => Ok(await _db.GetFastRecordsAsync(id, from, to));

    [HttpGet("{id}/csv")]
    public async Task<IActionResult> ExportCsv(int id, [FromQuery] DateTime? from = null, [FromQuery] DateTime? to = null)
    {
        var stream = new MemoryStream();
        await _db.ExportFastRecordsToCsvAsync(id, stream, from, to);
        stream.Position = 0;
        return File(stream, "text/csv", $"fast_{id}.csv");
    }
}
```
> `FastController`는 attribute routing이라 별도 DI 등록 불필요(`AddControllers`가 자동 스캔).
> `FastSessionStartRequest`/`FastSessionCreateRequest`/`FastQueryResult` 레코드 정의를
> `IExperionServices.cs:130~` 에서 확인하고 생성자 인자 순서를 맞출 것.

### fast.js:389 — 태그 목록
```javascript
// 변경 전: /api/pointbuilder/points (없음)
// 변경 후: 기존 태그 API 재사용
const res = await fetch('/api/gateway/tags?limit=500');   // 또는 /api/hc900/tags
```

---

## 수정 5: `t2s.js` — Text-to-SQL 탭

`/api/text-to-sql/execute` 없음(`execute-mcp`만 존재). 호출부 t2s.js:184, 449, 608.

**옵션 A (빠름) — JS를 execute-mcp로 통일:**
```javascript
// t2s.js의 '/api/text-to-sql/execute' 3곳을 모두 '/api/text-to-sql/execute-mcp' 로 교체
```
**옵션 B — 컨트롤러에 execute 신설** (MCP 없이 직접 실행이 필요할 때만):
```csharp
// TextToSqlController.cs
[HttpPost("execute")]
public async Task<IActionResult> ExecuteDirect([FromBody] ExecuteSqlRequest req)
{
    var v = _validator.Validate(req.Sql);
    if (!v.IsValid) return BadRequest(new { error = v.ErrorMessage });
    // Npgsql 직접 실행 (execute-mcp에서 MCP pivot 제외한 버전)
    return Ok(result);
}
```
> MCP 경로가 이미 정상 동작 중이면 **옵션 A 권장**(코드 최소화).

---

## 체크리스트

```
[ ] 0. (운영) Setup 탭에서 컨트롤러 IP 192.168.0.230 → 실제 HC900 IP(예 0.240) 교정 후 재시작
        → gateway health가 Serving 되어야 데이터가 표시됨 (코드와 무관, 병행 필수)

  ── JS만 수정 (백엔드 무변경) ──
[ ] 1. hist.js:42  /api/history/tagnames → /api/history/tags
[ ] 2. hist.js:98  query GET → POST + body DTO
[ ] 3. evt.js      3함수: /api/event-history/* → POST /api/events/query (+ realtime 필터)
[ ] 4. write.js    4함수: /api/points/* → POST /api/gateway/write (필드 PascalCase 주의)
[ ] 5. write.html + write.js  컨트롤러 드롭다운 추가
[ ] 6. fast.js:389 /api/pointbuilder/points → /api/gateway/tags
[ ] 7. t2s.js      /api/text-to-sql/execute → execute-mcp (옵션 A)

  ── 백엔드 신규 ──
[ ] 8. FastController.cs 신규 작성 (수정 4)
[ ] 9. HypertableController 신설 (수정 1-3) — hist 탭 하이퍼테이블 패널

[ ] 10. dotnet build 확인
[ ] 11. 탭별 동작 확인: write → evt → hist → fast → t2s
```

---

## 검증 부록 (재현 명령)

```bash
# DB 스키마가 이미 정상임을 재확인 (오진 검증용)
/tmp/hc900_venv/bin/python3 -c "
import psycopg2
c=psycopg2.connect(host='localhost',dbname='iiot_platform',user='postgres',password='postgres')
cur=c.cursor()
for t in ['realtime_table','history_table','event_history_table','hc900_map_master','tag_metadata']:
    cur.execute(\"SELECT 1 FROM information_schema.columns WHERE table_schema='hc900' AND table_name=%s AND column_name='controller_id'\",(t,))
    print(t, 'controller_id:', 'YES' if cur.fetchone() else 'NO')
"

# 엔드포인트 존재 여부 (application/json=있음, text/html=없음)
for p in /api/history/tags /api/history/tagnames /api/fast/sessions \
         /api/event-history/digital-tags /api/pointbuilder/points; do
  printf "%-40s %s\n" "$p" "$(curl -s -o /dev/null -w '%{content_type}' http://localhost:5000$p)"
done

# 게이트웨이 실제 연결 상태
curl -s http://localhost:5000/api/gateway/health
```
