Initial commit: HC900 Crawler
Honeywell HC900을 Modbus TCP로 직접 폴링 → gRPC → C# 크롤러 → PostgreSQL. 기존 Experion OPC UA 데이터 경로를 HC900 직접 통신으로 대체. - industrial-comm/cpp: C++ Modbus 게이트웨이 (gRPC 서버) - src: C# .NET 8 ASP.NET Core 크롤러 + 웹 UI (3-Layer) - mcp-server: Python FastMCP (RAG/NL2SQL/P&ID) - 다중 컨트롤러(N-Controller) 지원 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
413
TAB_FIX_WORK_ORDER.md
Normal file
413
TAB_FIX_WORK_ORDER.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user