# 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 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
``` ```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 _sessions = new(); public FastController(IExperionDbService db) => _db = db; [HttpGet("sessions")] public async Task GetSessions() => Ok(await _db.GetFastSessionsAsync()); [HttpGet("{id}")] public async Task GetSession(int id) { var s = await _db.GetFastSessionAsync(id); return s == null ? NotFound() : Ok(s); } [HttpPost("start")] public async Task 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 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 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 Pin(int id, [FromBody] bool pinned) { await _db.UpdateFastSessionPinnedAsync(id, pinned); return Ok(new { success = true }); } [HttpGet("{id}/records")] public async Task GetRecords(int id, [FromQuery] DateTime? from = null, [FromQuery] DateTime? to = null) => Ok(await _db.GetFastRecordsAsync(id, from, to)); [HttpGet("{id}/csv")] public async Task 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 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 ```