Realtime DB 추가 및 Historical DB추가
This commit is contained in:
365
CLAUDE.md
365
CLAUDE.md
@@ -1,5 +1,10 @@
|
||||
# ExperionCrawler — 작업 이력
|
||||
|
||||
## 작업 규칙
|
||||
- 복잡한 작업은 항상 todo 목록 먼저 생성
|
||||
- 각 단계 시작 전 todo 목록 확인
|
||||
- 단계 완료 후 즉시 completed 표시
|
||||
|
||||
## 완료된 작업
|
||||
|
||||
### 노드맵 대시보드 구현 (2026-04-14)
|
||||
@@ -27,6 +32,7 @@ node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를
|
||||
|
||||
### 이름 필터 드롭다운 OR 조건 검색 (2026-04-14)
|
||||
|
||||
|
||||
노드맵 대시보드의 이름 검색을 텍스트 입력에서 `name` 컬럼 고유값 풀다운 메뉴 4개로 교체, OR 조건 최대 4개 동시 선택 가능하도록 확장했다.
|
||||
|
||||
#### 수정된 파일
|
||||
@@ -42,3 +48,362 @@ node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를
|
||||
|
||||
#### 빌드 결과
|
||||
- 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공
|
||||
|
||||
---
|
||||
|
||||
## 구현 완료 (2026-04-14, todo.md 전항목)
|
||||
|
||||
### 빌드 결과
|
||||
- 경고 6건 (기존 3건 + 신규 3건 OPC SDK deprecated API 경고), **에러 0건** — 빌드 성공
|
||||
|
||||
---
|
||||
|
||||
## 버그 수정 이력 (2026-04-14)
|
||||
|
||||
### 버그 1 — OPC UA 연결 시 OS TCP 타임아웃(최대 127초) 문제
|
||||
|
||||
#### 증상
|
||||
- 접속 테스트 버튼을 눌렀을 때 수분간 응답 없는 것처럼 보임
|
||||
- `ExperionRealtimeService`: "연결 오류, 30초 후 재시도" 로그가 매우 늦게 출력됨
|
||||
- 오류: `System.Net.Sockets.SocketException (110): Connection timed out`
|
||||
|
||||
#### 원인
|
||||
Linux에서 OPC UA 서버 IP가 응답 없음(firewall/unreachable)이면 OS TCP SYN 재전송 타임아웃이 최대 127초까지 걸림. `TransportQuotas.OperationTimeout`은 OPC UA 프로토콜 레벨 타임아웃이라 TCP connect 단계에는 적용되지 않음.
|
||||
|
||||
#### 수정 파일
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|----------|
|
||||
| `ExperionOpcClient.cs` | `SelectEndpointAsync`에 `CancellationTokenSource(10초)` 추가 — DiscoveryClient 생성 시 10초 타임아웃 적용 |
|
||||
| `ExperionRealtimeService.cs` | 동일하게 `SelectEndpointAsync` 10초 타임아웃 적용 |
|
||||
|
||||
#### 결과
|
||||
서버 미응답 시 127초 대기 → **10초 이내 실패** 처리
|
||||
|
||||
---
|
||||
|
||||
### 버그 2 — PostgreSQL `sorry, too many clients already` (SQLSTATE 53300)
|
||||
|
||||
#### 증상
|
||||
구독 시작 후 실시간 값 수신 시 터미널에 다량의 에러:
|
||||
```
|
||||
Npgsql.PostgresException (0x80004005): 53300: sorry, too many clients already
|
||||
at ExperionDbService.UpdateLiveValueAsync(...)
|
||||
at ExperionRealtimeService.<<OnNotification>b__0>d.MoveNext()
|
||||
```
|
||||
|
||||
#### 원인
|
||||
`OnNotification` 콜백이 포인트마다 `Task.Run` → 새 DI 스코프 → 새 `DbContext` → 새 DB 커넥션을 열었음. 2000여개 포인트가 동시에 값 변경 콜백을 받으면 순식간에 PostgreSQL `max_connections`(기본 100) 초과.
|
||||
|
||||
```
|
||||
값 변경 콜백 × 2000개 → Task.Run × 2000개 → DB 커넥션 × 2000개 → 💥
|
||||
```
|
||||
|
||||
#### 수정 파일
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|----------|
|
||||
| `IExperionServices.cs` | `BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate>)` 인터페이스 추가, `LiveValueUpdate` record 추가 |
|
||||
| `ExperionDbContext.cs` | `BatchUpdateLiveValuesAsync` 구현 — 단일 DbContext에서 순차 ExecuteUpdateAsync |
|
||||
| `ExperionRealtimeService.cs` | `OnNotification`에서 `Task.Run` 제거 → `ConcurrentDictionary`에 최신값만 기록. 별도 `FlushLoopAsync` 태스크가 500ms마다 단일 DbContext로 배치 업데이트 |
|
||||
|
||||
#### 수정 후 구조
|
||||
```
|
||||
값 변경 콜백 × N개 → ConcurrentDictionary[nodeId] = 최신값
|
||||
↓ 500ms마다
|
||||
단일 DbContext → BatchUpdateLiveValuesAsync → DB 커넥션 1개
|
||||
```
|
||||
|
||||
#### 결과
|
||||
- DB 커넥션 동시 사용 수: 2000개 → **최대 1개**
|
||||
- 500ms 내 중복 변경은 최신값 1건만 DB에 반영 (deduplication)
|
||||
- 빌드: 경고 6건(기존 동일), **에러 0건**
|
||||
|
||||
---
|
||||
|
||||
### 버그 3 — 대시보드 탭 진입 시 자동 API 호출로 인한 CPU/브라우저 버벅임
|
||||
|
||||
#### 증상
|
||||
- **노드맵 대시보드** 탭 진입 시 CPU 과부하, 페이지 버벅임
|
||||
- **포인트빌더** 탭 진입 시 동일 증상
|
||||
- **이력 조회** 탭 진입 시 한참 동안 열리지 않음
|
||||
|
||||
#### 원인 (항목별)
|
||||
|
||||
| 탭 | 자동 호출 API | 무거운 이유 |
|
||||
|----|--------------|------------|
|
||||
| 노드맵 대시보드 | `/api/nodemap/stats` + `/api/nodemap/names` + `/api/nodemap/query` | stats: 5가지 집계 쿼리(COUNT×4, MAX, DISTINCT). 결과로 전체 조회까지 자동 실행 |
|
||||
| 포인트빌더 | `/api/nodemap/names` + `/api/nodemap/stats` | stats 집계 쿼리 (포인트빌더 dataType 드롭다운 채우기 용도) |
|
||||
| 이력 조회 | `/api/history/tagnames` → 드롭다운 8개에 2000개 옵션 삽입 | 8 × 2000 = 16,000개 DOM `<option>` 생성으로 브라우저 freeze |
|
||||
|
||||
#### 수정 내용
|
||||
|
||||
**공통 원칙**: 탭 진입 시 API 호출 0건. 사용자가 명시적으로 버튼을 눌렀을 때만 실행.
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|----------|
|
||||
| `app.js` | 탭 클릭 핸들러에서 `nmLoad()`, `pbLoad()`, `histLoad()` 자동 호출 제거 |
|
||||
| `app.js` | `nmReset()` 에서 `nmQuery(0)` 자동 호출 제거 |
|
||||
| `app.js` | `nmLoad()` → `nmLoadNames()`로 분리 (이름 드롭다운만, 버튼 클릭 시 호출) |
|
||||
| `app.js` | `nmLoad()` 내부의 통계 카드 렌더링 + `nmQuery(0)` 자동 호출 제거 |
|
||||
| `app.js` | `pbLoad()` 에서 `/api/nodemap/stats` 호출 제거 |
|
||||
| `app.js` | `histLoad()` 는 유지하되 탭 자동 호출 제거, "▼ 옵션 불러오기" 버튼 클릭 시에만 실행 |
|
||||
| `index.html` | 노드맵 대시보드: 통계 카드(`nm-stat-row`) 제거, 데이터타입 select → text input |
|
||||
| `index.html` | 노드맵 대시보드: 이름 드롭다운에 "▼ 옵션 불러오기" 버튼 추가 |
|
||||
| `index.html` | 포인트빌더: 데이터타입 select 2개 → text input 2개 (`Double`, `Int32` 등 직접 입력) |
|
||||
| `index.html` | 이력 조회: 태그 선택 드롭다운에 "▼ 옵션 불러오기" 버튼 추가 |
|
||||
|
||||
#### 결과 (탭별 진입 시 API 호출 수)
|
||||
|
||||
| 탭 | 이전 | 이후 |
|
||||
|----|------|------|
|
||||
| 노드맵 대시보드 | stats + names + query = **3건** | **0건** |
|
||||
| 포인트빌더 | names + stats = **2건** | names = **1건** |
|
||||
| 이력 조회 | tagnames = **1건** + DOM 16,000개 생성 | **0건** |
|
||||
|
||||
#### 주의 사항
|
||||
- `/api/nodemap/stats` 엔드포인트는 서버에 남아있으나 프론트엔드에서 호출하지 않음
|
||||
- 이름/태그 드롭다운은 "▼ 옵션 불러오기" 버튼으로 수동 로드
|
||||
- 데이터타입 필터는 text input 직접 입력 방식으로 변경 (API 불필요)
|
||||
|
||||
---
|
||||
|
||||
### 버그 4 — 포인트빌더 탭 진입 시 여전히 버벅임 (2026-04-14)
|
||||
|
||||
#### 증상
|
||||
버그 3 수정 이후에도 포인트빌더 탭 진입 시 버벅임 지속.
|
||||
|
||||
#### 원인
|
||||
버그 3 수정 시 탭 핸들러에서 `pbLoad()` 제거를 누락. `app.js`에 `if (tab === 'pb') pbLoad()` 가 그대로 남아 있었음. `pbLoad()`는 `/api/nodemap/names` 호출 → 8개 드롭다운에 전체 name 목록 삽입 → DOM 부하.
|
||||
|
||||
#### 수정 파일
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|----------|
|
||||
| `app.js` | 탭 핸들러에서 `if (tab === 'pb') pbLoad()` 제거 |
|
||||
| `index.html` | 포인트빌더 이름 선택 레이블 옆에 "▼ 옵션 불러오기" 버튼 추가 (`onclick="pbLoad()"`) |
|
||||
|
||||
#### 결과
|
||||
포인트빌더 탭 진입 시 API 호출 **0건**
|
||||
|
||||
---
|
||||
|
||||
### 기능 추가 — 실시간 구독 자동 재시작 플래그 (2026-04-14)
|
||||
|
||||
#### 배경
|
||||
앱 재기동 시 구독이 자동으로 재시작되지 않아 매번 수동으로 구독 시작 버튼을 눌러야 했음.
|
||||
히스토리 스냅샷이 구독 여부와 무관하게 무조건 실행되어 `livevalue = NULL` 행이 저장되는 문제도 존재.
|
||||
|
||||
#### 설계
|
||||
- 구독 시작 시 서버 설정을 `realtime_autostart.json` 파일로 저장 (앱 실행 디렉토리)
|
||||
- 앱 기동 시 (`IHostedService.StartAsync`) 파일 존재 여부 확인 → 있으면 자동 구독 시작
|
||||
- 구독 중지 시 파일 삭제 → 재기동 후 자동 시작 없음
|
||||
- `ExperionHistoryService`가 `IExperionRealtimeService.GetStatus().Running` 확인 → OFF이면 스냅샷 건너뜀
|
||||
|
||||
#### 수정 파일
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|----------|
|
||||
| `ExperionRealtimeService.cs` | `StartAsync(cfg)` 시 `realtime_autostart.json` 저장; `StopAsync()` 시 파일 삭제; `StartAsync(CancellationToken)` (IHostedService)에서 파일 읽어 자동 재시작 |
|
||||
| `ExperionHistoryService.cs` | `IExperionRealtimeService` 생성자 주입; 스냅샷 전 `GetStatus().Running` 체크 → false이면 `continue` |
|
||||
|
||||
#### 동작 흐름
|
||||
```
|
||||
구독 시작 버튼 → realtime_autostart.json 저장 → OPC UA 구독 시작
|
||||
앱 재기동 → 파일 감지 → 자동 구독 시작
|
||||
구독 중지 버튼 → 파일 삭제 → 재기동 후 자동 시작 안 함
|
||||
히스토리 서비스 → Running=false이면 스냅샷 건너뜀
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 기능 추가 — 수동 포인트 추가 시 OPC UA 핫 추가 및 유효성 검증 (2026-04-14)
|
||||
|
||||
#### 배경
|
||||
수동으로 포인트를 추가해도 기존 구독에는 반영되지 않아 구독 재시작이 필요했음.
|
||||
잘못된 node_id 입력 시 DB에만 저장되고 `livevalue`가 영원히 NULL인 문제도 존재.
|
||||
|
||||
#### 설계
|
||||
- 수동 추가 시 DB 저장 후 구독 중이면 `MonitoredItem` 핫 추가 (`ApplyChanges()`)
|
||||
- OPC UA 서버 응답 상태 확인 → bad 상태코드이면 subscription 제거 + DB 롤백 + 에러 반환
|
||||
- 구독 중이 아닌 경우 DB에만 저장 → 다음 구독 시작 시 자동 포함
|
||||
|
||||
#### 수정 파일
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|----------|
|
||||
| `IExperionServices.cs` | `IExperionRealtimeService`에 `AddMonitoredItemAsync(string nodeId)` 추가 (반환: `(bool Success, string Message)`) |
|
||||
| `ExperionRealtimeService.cs` | `AddMonitoredItemAsync` 구현 — MonitoredItem 생성, `ApplyChanges()`, 상태 확인, bad이면 롤백 |
|
||||
| `ExperionControllers.cs` | `ExperionPointBuilderController`에 `IExperionRealtimeService` 주입; `Add` 엔드포인트에서 DB 저장 후 `AddMonitoredItemAsync` 호출 → 실패 시 `DeleteRealtimePointAsync`로 DB 롤백 |
|
||||
|
||||
#### 동작 흐름
|
||||
```
|
||||
수동 추가 요청
|
||||
├── DB 저장
|
||||
├── 구독 중 아님 → 성공 ("다음 구독 시작 시 자동 포함")
|
||||
└── 구독 중
|
||||
├── OPC UA ApplyChanges() → Good → 즉시 구독 포함, 성공
|
||||
└── OPC UA → Bad → subscription 제거 + DB 롤백 + 에러 반환
|
||||
```
|
||||
|
||||
#### 빌드 결과
|
||||
- 경고 8건 (기존 6건 + OPC SDK deprecated 2건), **에러 0건** — 빌드 성공
|
||||
|
||||
---
|
||||
|
||||
### 성능 분석 — 1,699포인트 기준 CPU 부하 추정 (2026-04-14)
|
||||
|
||||
#### 전제 조건
|
||||
- 실시간 포인트: 1,699개
|
||||
- 히스토리 스냅샷 주기: 60초
|
||||
- 실시간 배치 flush 주기: 500ms
|
||||
|
||||
#### 히스토리 스냅샷 (60초마다)
|
||||
|
||||
- 작업: `realtime_table` 1,699행 SELECT → `history_table` INSERT 1,699행
|
||||
- 특성: 1분에 1번 순간 burst, 수십 ms 수준
|
||||
- 앱 CPU: EF Core 객체 생성 1,699개 → 거의 무시 가능
|
||||
- **결론: 평균 CPU 기여 < 1%**
|
||||
|
||||
#### 실시간 livevalue 갱신 (500ms마다 배치)
|
||||
|
||||
- 작업: `ExecuteUpdateAsync` × (변경된 포인트 수)건 / 500ms
|
||||
- OPC UA는 값이 바뀔 때만 콜백 → 전 포인트가 동시에 변경되는 경우는 드묾
|
||||
- 실제 변경 수: 수십~수백건/500ms가 일반적
|
||||
- **결론: 변경 포인트 수에 비례, 대부분의 경우 낮음**
|
||||
|
||||
#### 종합
|
||||
|
||||
| 작업 | 주기 | 예상 CPU |
|
||||
|------|------|----------|
|
||||
| 히스토리 스냅샷 | 60초/회 | 무시 가능 (< 1%) |
|
||||
| 실시간 배치 업데이트 | 500ms/회 | 변경 포인트 수에 비례 |
|
||||
| **합계** | - | **단일 코어 기준 5~15% 이내** |
|
||||
|
||||
실제 병목은 CPU보다 **PostgreSQL I/O와 커넥션 처리**쪽이 먼저 나타남. 현재 구조(단일 DbContext, 배치 flush)는 이미 최적화된 상태.
|
||||
|
||||
---
|
||||
|
||||
### 성능 분석 — 멀티모니터 4대 실시간 폴링 부하 (2026-04-14)
|
||||
|
||||
#### 시나리오
|
||||
- 웹페이지에서 `realtime_table` 조회, 페이지당 200개, 2초 간격 갱신
|
||||
- 멀티모니터 4대에서 4개의 브라우저 탭/창이 동시 동작
|
||||
|
||||
#### 부하 추정
|
||||
|
||||
| 항목 | 계산 | 평가 |
|
||||
|------|------|------|
|
||||
| 서버 요청 수 | 4탭 × 1회/2초 = **2 req/s** | 무시 가능 |
|
||||
| DB 쿼리 | SELECT 200행 × 2회/s | 경량 |
|
||||
| 응답 크기 | 200행 × ~150 bytes ≈ **30KB/응답** | 소량 |
|
||||
| 네트워크 | 4 × 30KB / 2s = **60KB/s** | 거의 없음 |
|
||||
| 브라우저 RAM | 탭당 60~100MB × 4 = **240~400MB** | 보통 수준 |
|
||||
|
||||
**결론: 서버 부하 크지 않음. 일반 개발용 PC(i5급, 8GB RAM)에서 충분히 감당 가능.**
|
||||
|
||||
#### 실질적 병목 — 브라우저 DOM 재렌더링
|
||||
|
||||
현재 `pbRender()`는 `tbl.innerHTML`로 테이블 전체를 교체하는 방식 (full re-render).
|
||||
- 200행 × 4탭 × 2초마다 전체 재생성 → 체감 가능한 CPU 사용
|
||||
|
||||
#### 결정 사항
|
||||
**실시간 모니터링 페이지 구현 시 반드시 incremental DOM update 방식 사용**
|
||||
- 이미 그려진 `<td>` 셀의 `.textContent`만 갱신 (값이 바뀐 셀만)
|
||||
- `innerHTML` 전체 교체 금지
|
||||
- 구조 변경(행 추가/삭제) 시에만 DOM 재구성 허용
|
||||
|
||||
---
|
||||
|
||||
## 구현 계획 (참고용)
|
||||
|
||||
### Task 1 — RealtimeTable + 포인트빌더 대시보드
|
||||
|
||||
#### 개요
|
||||
- `realtime_table` PostgreSQL 테이블 생성: `id, tagname, node_id, livevalue, timestamp`
|
||||
- `tagname`: `node_id.Substring(node_id.LastIndexOf(':') + 1)` (마지막 ':' 오른쪽 문자열, 없으면 전체)
|
||||
- 소스: `node_map_master WHERE name IN (...) AND data_type = 'Double'`
|
||||
|
||||
#### 수정 파일
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `ExperionEntities.cs` | `RealtimePoint` 엔티티 추가 (`realtime_table` 매핑) |
|
||||
| `IExperionServices.cs` | `IExperionDbService`에 `BuildRealtimeTableAsync`, `GetRealtimePointsAsync`, `AddRealtimePointAsync`, `DeleteRealtimePointAsync` 추가 |
|
||||
| `ExperionDbContext.cs` | `DbSet<RealtimePoint>`, 테이블 DDL, 4개 서비스 메서드 구현 |
|
||||
| `ExperionControllers.cs` | `ExperionPointBuilderController` 추가 (POST /api/pointbuilder/build, GET /api/pointbuilder/points, POST /api/pointbuilder/add, DELETE /api/pointbuilder/{id}) |
|
||||
| `index.html` | 06번 탭 '포인트빌더' 추가 — name 드롭다운 8개, dataType 드롭다운, 빌드 버튼, 수동 node_id 입력, 포인트 테이블 |
|
||||
| `app.js` | `pbLoad()`, `pbBuild()`, `pbAddManual()`, `pbDelete(id)`, `pbRender()` 구현 |
|
||||
| `style.css` | 포인트빌더 전용 스타일 추가 |
|
||||
|
||||
#### 설계 결정
|
||||
- `BuildRealtimeTableAsync`는 기존 레코드를 모두 지우고 재생성 (TRUNCATE + INSERT)
|
||||
- 수동 추가(`AddRealtimePointAsync`)는 `tagname`을 자동 추출해서 삽입
|
||||
- 약 2000건 → 페이지네이션 불필요, 전체 목록을 클라이언트 측 테이블로 렌더링
|
||||
|
||||
---
|
||||
|
||||
### Task 2 — OPC UA 실시간 구독 (livevalue 업데이트)
|
||||
|
||||
#### 개요
|
||||
- OPC UA Subscription + MonitoredItem API 사용 (값 변경 시에만 콜백)
|
||||
- `IExperionRealtimeService` 인터페이스 + `ExperionRealtimeService` BackgroundService 신규 파일
|
||||
- 서버 접속 설정은 `appsettings.json`에서 읽음 (기존 `ExperionServerConfig` 구조 재사용)
|
||||
- 값 변경 콜백 → `realtime_table.livevalue` 업데이트 + `timestamp` 갱신
|
||||
|
||||
#### 수정 파일
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `IExperionServices.cs` | `IExperionRealtimeService` 인터페이스, `IExperionDbService`에 `UpdateLiveValueAsync` 추가 |
|
||||
| `ExperionDbContext.cs` | `UpdateLiveValueAsync` 구현 |
|
||||
| `ExperionRealtimeService.cs` (신규) | `BackgroundService` 구현 — Subscription 생성, MonitoredItem 등록, 콜백 처리 |
|
||||
| `ExperionControllers.cs` | `ExperionRealtimeController` 추가 (POST /api/realtime/start, POST /api/realtime/stop, GET /api/realtime/status) |
|
||||
| `Program.cs` | `AddHostedService<ExperionRealtimeService>()` 등록 |
|
||||
| `index.html` + `app.js` | 포인트빌더 탭에 실시간 시작/정지 버튼, 상태 표시, livevalue 폴링(3초) 추가 |
|
||||
|
||||
#### 설계 결정
|
||||
- OPC UA Subscription: `PublishingInterval = 1000ms`
|
||||
- MonitoredItem: `SamplingInterval = 500ms`, `DeadBandType = None`
|
||||
- 값 변경 없으면 콜백 없음 → DB 업데이트 없음 (OPC UA 규약 준수)
|
||||
- 서비스 재시작 시 자동 재연결 로직 포함 (30초 재시도)
|
||||
|
||||
---
|
||||
|
||||
### Task 3 — HistoryTable (시계열 스냅샷)
|
||||
|
||||
#### 개요
|
||||
- `history_table`: `id, tagname, node_id, value, recorded_at`
|
||||
- `ExperionHistoryService` BackgroundService → 설정된 주기(기본 60초)마다 `realtime_table` 전체를 스냅샷
|
||||
- 주기는 `appsettings.json: "HistoryIntervalSeconds": 60` 에서 읽음
|
||||
|
||||
#### 수정 파일
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `ExperionEntities.cs` | `HistoryRecord` 엔티티 추가 (`history_table` 매핑) |
|
||||
| `IExperionServices.cs` | `IExperionDbService`에 `SnapshotToHistoryAsync` 추가 |
|
||||
| `ExperionDbContext.cs` | `DbSet<HistoryRecord>`, 테이블 DDL, `SnapshotToHistoryAsync` 구현 |
|
||||
| `ExperionHistoryService.cs` (신규) | `BackgroundService` — 주기적 `SnapshotToHistoryAsync` 호출 |
|
||||
| `Program.cs` | `AddHostedService<ExperionHistoryService>()` 등록 |
|
||||
|
||||
---
|
||||
|
||||
### Task 4 — HistoryTable 웹페이지
|
||||
|
||||
#### 개요
|
||||
- 07번 탭 '이력 조회' 추가
|
||||
- tagname 드롭다운 최대 8개 선택 (다중 선택으로 열 구성)
|
||||
- 시작 시간 / 종료 시간 범위 필터
|
||||
- 결과 테이블: tagname이 열 헤더, recorded_at이 행
|
||||
|
||||
#### 수정 파일
|
||||
|
||||
| 파일 | 내용 |
|
||||
|------|------|
|
||||
| `IExperionServices.cs` | `IExperionDbService`에 `GetTagNamesAsync`, `QueryHistoryAsync` 추가; `HistoryQueryResult` record 추가 |
|
||||
| `ExperionDbContext.cs` | `GetTagNamesAsync`, `QueryHistoryAsync` 구현 |
|
||||
| `ExperionControllers.cs` | `ExperionHistoryController` 추가 (GET /api/history/tagnames, GET /api/history/query) |
|
||||
| `index.html` | 07번 탭 '이력 조회' + `#pane-hist` 섹션 추가 |
|
||||
| `app.js` | `histLoad()`, `histQuery()`, `histRender()` 구현 |
|
||||
| `style.css` | 이력 조회 전용 스타일 추가 |
|
||||
|
||||
@@ -48,3 +48,14 @@ public class ExperionNodeMapCrawlRequestDto
|
||||
public ExperionServerConfigDto ServerConfig { get; set; } = new();
|
||||
public int MaxDepth { get; set; } = 10;
|
||||
}
|
||||
|
||||
public class PointBuilderBuildDto
|
||||
{
|
||||
public List<string> Names { get; set; } = new();
|
||||
public List<string> DataTypes { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PointBuilderAddDto
|
||||
{
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ExperionCrawler.Core.Application.Interfaces;
|
||||
|
||||
@@ -54,8 +55,38 @@ public interface IExperionDbService
|
||||
int? minLevel, int? maxLevel, string? nodeClass,
|
||||
IEnumerable<string>? names, string? nodeId, string? dataType,
|
||||
int limit, int offset);
|
||||
|
||||
// ── RealtimeTable ─────────────────────────────────────────────────────────
|
||||
Task<int> BuildRealtimeTableAsync(IEnumerable<string> names, IEnumerable<string> dataTypes);
|
||||
Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync();
|
||||
Task<RealtimePoint> AddRealtimePointAsync(string nodeId);
|
||||
Task<bool> DeleteRealtimePointAsync(int id);
|
||||
Task<int> UpdateLiveValueAsync(string nodeId, string? value, DateTime timestamp);
|
||||
Task<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> updates);
|
||||
|
||||
// ── HistoryTable ──────────────────────────────────────────────────────────
|
||||
Task<int> SnapshotToHistoryAsync();
|
||||
Task<IEnumerable<string>> GetTagNamesAsync();
|
||||
Task<HistoryQueryResult> QueryHistoryAsync(
|
||||
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit);
|
||||
}
|
||||
|
||||
// ── Realtime Service ─────────────────────────────────────────────────────────
|
||||
|
||||
public interface IExperionRealtimeService
|
||||
{
|
||||
Task StartAsync(ExperionServerConfig cfg);
|
||||
Task StopAsync();
|
||||
RealtimeServiceStatus GetStatus();
|
||||
/// <summary>
|
||||
/// 구독 중이면 MonitoredItem 핫 추가 후 OPC UA 서버 응답 검증.
|
||||
/// 구독 중이지 않으면 (true, "구독 중 아님") 반환.
|
||||
/// </summary>
|
||||
Task<(bool Success, string Message)> AddMonitoredItemAsync(string nodeId);
|
||||
}
|
||||
|
||||
public record RealtimeServiceStatus(bool Running, int SubscribedCount, string Message);
|
||||
|
||||
// ── Status Code ──────────────────────────────────────────────────────────────
|
||||
|
||||
public interface IExperionStatusCodeService
|
||||
@@ -78,3 +109,6 @@ public record ExperionNodeMapEntry(int Level, string NodeClass, string DisplayNa
|
||||
public record ExperionNodeMapResult(bool Success, IEnumerable<ExperionNodeMapEntry> Nodes, int TotalCount, string? ErrorMessage = null);
|
||||
public record NodeMapStats(int Total, int ObjectCount, int VariableCount, int MaxLevel, IEnumerable<string> DataTypes);
|
||||
public record NodeMapQueryResult(int Total, IEnumerable<NodeMapMaster> Items);
|
||||
public record HistoryQueryResult(IEnumerable<string> TagNames, IEnumerable<HistoryRow> Rows);
|
||||
public record HistoryRow(DateTime RecordedAt, IReadOnlyDictionary<string, string?> Values);
|
||||
public record LiveValueUpdate(string NodeId, string? Value, DateTime Timestamp);
|
||||
|
||||
@@ -64,6 +64,28 @@ public class NodeMapMaster
|
||||
[Column("data_type")]public string DataType { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>realtime_table — 실시간 모니터링 포인트</summary>
|
||||
[Table("realtime_table")]
|
||||
public class RealtimePoint
|
||||
{
|
||||
[Column("id")] public int Id { get; set; }
|
||||
[Column("tagname")] public string TagName { get; set; } = string.Empty;
|
||||
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
|
||||
[Column("livevalue")] public string? LiveValue { get; set; }
|
||||
[Column("timestamp")] public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>history_table — 시계열 이력 스냅샷</summary>
|
||||
[Table("history_table")]
|
||||
public class HistoryRecord
|
||||
{
|
||||
[Column("id")] public int Id { get; set; }
|
||||
[Column("tagname")] public string TagName { get; set; } = string.Empty;
|
||||
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
|
||||
[Column("value")] public string? Value { get; set; }
|
||||
[Column("recorded_at")] public DateTime RecordedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>statuscode.json 항목</summary>
|
||||
public class ExperionStatusCodeInfo
|
||||
{
|
||||
|
||||
@@ -14,6 +14,8 @@ public class ExperionDbContext : DbContext
|
||||
public DbSet<ExperionRecord> ExperionRecords => Set<ExperionRecord>();
|
||||
public DbSet<RawNodeMap> RawNodeMaps => Set<RawNodeMap>();
|
||||
public DbSet<NodeMapMaster> NodeMapMasters => Set<NodeMapMaster>();
|
||||
public DbSet<RealtimePoint> RealtimePoints => Set<RealtimePoint>();
|
||||
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -37,6 +39,20 @@ public class ExperionDbContext : DbContext
|
||||
e.HasIndex(x => x.NodeId);
|
||||
e.HasIndex(x => x.Level);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RealtimePoint>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.HasIndex(x => x.NodeId).IsUnique();
|
||||
e.HasIndex(x => x.TagName);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<HistoryRecord>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.HasIndex(x => x.TagName);
|
||||
e.HasIndex(x => x.RecordedAt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +99,31 @@ public class ExperionDbService : IExperionDbService
|
||||
)
|
||||
""");
|
||||
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS realtime_table (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tagname TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL UNIQUE,
|
||||
livevalue TEXT,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""");
|
||||
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS history_table (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tagname TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
value TEXT,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""");
|
||||
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_tagname ON history_table(tagname)");
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_recorded_at ON history_table(recorded_at)");
|
||||
|
||||
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료");
|
||||
return true;
|
||||
}
|
||||
@@ -162,6 +203,155 @@ public class ExperionDbService : IExperionDbService
|
||||
return new NodeMapStats(total, objectCount, variableCount, maxLevel, dataTypes);
|
||||
}
|
||||
|
||||
// ── RealtimeTable ─────────────────────────────────────────────────────────
|
||||
|
||||
private static string ExtractTagName(string nodeId)
|
||||
{
|
||||
var idx = nodeId.LastIndexOf(':');
|
||||
return idx >= 0 ? nodeId[(idx + 1)..] : nodeId;
|
||||
}
|
||||
|
||||
public async Task<int> BuildRealtimeTableAsync(
|
||||
IEnumerable<string> names, IEnumerable<string> dataTypes)
|
||||
{
|
||||
var nameList = names.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||
var dtList = dataTypes.Where(d => !string.IsNullOrEmpty(d)).ToList();
|
||||
|
||||
var q = _ctx.NodeMapMasters.AsQueryable();
|
||||
if (nameList.Count > 0) q = q.Where(x => nameList.Contains(x.Name));
|
||||
if (dtList.Count > 0) q = q.Where(x => dtList.Contains(x.DataType));
|
||||
|
||||
var sources = await q.ToListAsync();
|
||||
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
|
||||
|
||||
var points = sources.Select(s => new RealtimePoint
|
||||
{
|
||||
TagName = ExtractTagName(s.NodeId),
|
||||
NodeId = s.NodeId,
|
||||
LiveValue = null,
|
||||
Timestamp = DateTime.UtcNow
|
||||
}).ToList();
|
||||
|
||||
await _ctx.RealtimePoints.AddRangeAsync(points);
|
||||
var saved = await _ctx.SaveChangesAsync();
|
||||
_logger.LogInformation("[ExperionDb] realtime_table 빌드: {Count}건", saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync()
|
||||
=> await _ctx.RealtimePoints.OrderBy(x => x.TagName).ToListAsync();
|
||||
|
||||
public async Task<RealtimePoint> AddRealtimePointAsync(string nodeId)
|
||||
{
|
||||
var existing = await _ctx.RealtimePoints.FirstOrDefaultAsync(x => x.NodeId == nodeId);
|
||||
if (existing != null) return existing;
|
||||
|
||||
var point = new RealtimePoint
|
||||
{
|
||||
TagName = ExtractTagName(nodeId),
|
||||
NodeId = nodeId,
|
||||
LiveValue = null,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
_ctx.RealtimePoints.Add(point);
|
||||
await _ctx.SaveChangesAsync();
|
||||
_logger.LogInformation("[ExperionDb] 수동 추가: {NodeId}", nodeId);
|
||||
return point;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteRealtimePointAsync(int id)
|
||||
{
|
||||
var point = await _ctx.RealtimePoints.FindAsync(id);
|
||||
if (point == null) return false;
|
||||
_ctx.RealtimePoints.Remove(point);
|
||||
await _ctx.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> UpdateLiveValueAsync(string nodeId, string? value, DateTime timestamp)
|
||||
{
|
||||
return await _ctx.RealtimePoints
|
||||
.Where(x => x.NodeId == nodeId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.LiveValue, value)
|
||||
.SetProperty(x => x.Timestamp, timestamp));
|
||||
}
|
||||
|
||||
public async Task<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> updates)
|
||||
{
|
||||
var list = updates.ToList();
|
||||
if (list.Count == 0) return 0;
|
||||
|
||||
// 단일 DbContext(단일 DB 커넥션)에서 순차 업데이트 — 커넥션 폭발 방지
|
||||
int total = 0;
|
||||
foreach (var u in list)
|
||||
{
|
||||
total += await _ctx.RealtimePoints
|
||||
.Where(x => x.NodeId == u.NodeId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.LiveValue, u.Value)
|
||||
.SetProperty(x => x.Timestamp, u.Timestamp));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ── HistoryTable ──────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<int> SnapshotToHistoryAsync()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var points = await _ctx.RealtimePoints.ToListAsync();
|
||||
if (points.Count == 0) return 0;
|
||||
|
||||
var rows = points.Select(p => new HistoryRecord
|
||||
{
|
||||
TagName = p.TagName,
|
||||
NodeId = p.NodeId,
|
||||
Value = p.LiveValue,
|
||||
RecordedAt = now
|
||||
}).ToList();
|
||||
|
||||
await _ctx.HistoryRecords.AddRangeAsync(rows);
|
||||
var saved = await _ctx.SaveChangesAsync();
|
||||
_logger.LogInformation("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> GetTagNamesAsync()
|
||||
=> await _ctx.RealtimePoints.Select(x => x.TagName).OrderBy(x => x).ToListAsync();
|
||||
|
||||
public async Task<HistoryQueryResult> QueryHistoryAsync(
|
||||
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit)
|
||||
{
|
||||
var tags = tagNames.Where(t => !string.IsNullOrEmpty(t)).ToList();
|
||||
|
||||
var q = _ctx.HistoryRecords.AsQueryable();
|
||||
if (tags.Count > 0) q = q.Where(x => tags.Contains(x.TagName));
|
||||
if (from.HasValue) q = q.Where(x => x.RecordedAt >= from.Value);
|
||||
if (to.HasValue) q = q.Where(x => x.RecordedAt <= to.Value);
|
||||
|
||||
var rows = await q.OrderBy(x => x.RecordedAt)
|
||||
.Take(Math.Min(limit, 5000))
|
||||
.ToListAsync();
|
||||
|
||||
// recorded_at 기준으로 행을 묶어서 pivot 구성
|
||||
var grouped = rows
|
||||
.GroupBy(x => x.RecordedAt)
|
||||
.Select(g => new HistoryRow(
|
||||
g.Key,
|
||||
g.ToDictionary(r => r.TagName, r => r.Value)
|
||||
as IReadOnlyDictionary<string, string?>))
|
||||
.ToList();
|
||||
|
||||
var usedTags = tags.Count > 0
|
||||
? tags
|
||||
: rows.Select(x => x.TagName).Distinct().OrderBy(x => x).ToList();
|
||||
|
||||
return new HistoryQueryResult(usedTags, grouped);
|
||||
}
|
||||
|
||||
public async Task<NodeMapQueryResult> QueryMasterAsync(
|
||||
int? minLevel, int? maxLevel, string? nodeClass,
|
||||
IEnumerable<string>? names, string? nodeId, string? dataType,
|
||||
|
||||
63
src/Infrastructure/OpcUa/ExperionHistoryService.cs
Normal file
63
src/Infrastructure/OpcUa/ExperionHistoryService.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// 주기적으로 realtime_table 전체를 history_table 에 스냅샷 저장하는 BackgroundService.
|
||||
/// 간격은 appsettings.json: "HistoryIntervalSeconds" (기본 60초).
|
||||
/// </summary>
|
||||
public class ExperionHistoryService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<ExperionHistoryService> _logger;
|
||||
private readonly IExperionRealtimeService _realtimeService;
|
||||
private readonly int _intervalSeconds;
|
||||
|
||||
public ExperionHistoryService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<ExperionHistoryService> logger,
|
||||
IExperionRealtimeService realtimeService,
|
||||
IConfiguration config)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_realtimeService = realtimeService;
|
||||
_intervalSeconds = config.GetValue<int>("HistoryIntervalSeconds", 60);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("[HistoryService] 시작 — 간격: {Interval}초", _intervalSeconds);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(_intervalSeconds), stoppingToken);
|
||||
|
||||
// 실시간 구독이 OFF 상태이면 스냅샷 건너뜀
|
||||
if (!_realtimeService.GetStatus().Running)
|
||||
{
|
||||
_logger.LogDebug("[HistoryService] 구독 중지 상태 — 스냅샷 건너뜀");
|
||||
continue;
|
||||
}
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
var count = await db.SnapshotToHistoryAsync();
|
||||
_logger.LogInformation("[HistoryService] 스냅샷 저장: {Count}건", count);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[HistoryService] 스냅샷 저장 실패");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[HistoryService] 종료");
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,16 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
|
||||
// ── 엔드포인트 선택 (원본 로직 동일) ────────────────────────────────────
|
||||
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
|
||||
ApplicationConfiguration appConfig, string endpointUrl)
|
||||
ApplicationConfiguration appConfig, string endpointUrl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// OS TCP SYN 타임아웃(최대 127초)을 피하기 위해 10초 연결 타임아웃 적용
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
using var discovery = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, CancellationToken.None);
|
||||
appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, timeoutCts.Token);
|
||||
|
||||
var endpoints = await discovery.GetEndpointsAsync(null);
|
||||
|
||||
@@ -111,7 +116,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
{
|
||||
_logger.LogInformation("[ExperionOpc] TestConnection → {Url}", cfg.EndpointUrl);
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None);
|
||||
|
||||
_logger.LogInformation("정책 선택됨: {Policy}", endpoint.Description.SecurityPolicyUri);
|
||||
|
||||
@@ -147,7 +152,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None);
|
||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession");
|
||||
|
||||
foreach (var nodeId in nodeList)
|
||||
@@ -198,7 +203,7 @@ public class ExperionOpcClient : IExperionOpcClient
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildConfigAsync(cfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, ct);
|
||||
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession");
|
||||
|
||||
_logger.LogInformation("[ExperionOpc] 전체 노드맵 탐색 시작 (maxDepth={MaxDepth})", maxDepth);
|
||||
|
||||
434
src/Infrastructure/OpcUa/ExperionRealtimeService.cs
Normal file
434
src/Infrastructure/OpcUa/ExperionRealtimeService.cs
Normal file
@@ -0,0 +1,434 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using ExperionCrawler.Infrastructure.Certificates;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using ISession = Opc.Ua.Client.ISession;
|
||||
using StatusCodes = Opc.Ua.StatusCodes;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA Subscription 기반 실시간 livevalue 업데이트 서비스.
|
||||
/// 값이 변경될 때만 콜백을 받아 realtime_table 을 갱신합니다.
|
||||
/// </summary>
|
||||
public class ExperionRealtimeService : IExperionRealtimeService, IHostedService, IDisposable
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<ExperionRealtimeService> _logger;
|
||||
|
||||
private ISession? _session;
|
||||
private Subscription? _subscription;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _monitorTask;
|
||||
private Task? _flushTask;
|
||||
|
||||
// 콜백에서 최신 값만 기록 (노드당 1개 유지) → 500ms 배치 flush
|
||||
private readonly ConcurrentDictionary<string, (string? value, DateTime timestamp)>
|
||||
_pendingUpdates = new();
|
||||
|
||||
private volatile bool _running;
|
||||
private int _subscribedCount;
|
||||
private string _statusMsg = "중지됨";
|
||||
private ExperionServerConfig? _currentCfg;
|
||||
|
||||
// 자동 재시작 플래그 파일 경로
|
||||
private static readonly string FlagPath =
|
||||
Path.GetFullPath("realtime_autostart.json");
|
||||
|
||||
public ExperionRealtimeService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<ExperionRealtimeService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ── IHostedService ────────────────────────────────────────────────────────
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 앱 기동 시 플래그 파일이 있으면 자동 구독 시작
|
||||
if (!File.Exists(FlagPath)) return;
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(FlagPath, cancellationToken);
|
||||
var cfg = JsonSerializer.Deserialize<ExperionServerConfig>(json);
|
||||
if (cfg != null)
|
||||
{
|
||||
_logger.LogInformation("[Realtime] 자동 재시작 플래그 감지 — 구독 자동 시작");
|
||||
await StartAsync(cfg);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Realtime] 자동 재시작 플래그 읽기 실패 — 무시");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await StopAsync();
|
||||
}
|
||||
|
||||
// ── IExperionRealtimeService ──────────────────────────────────────────────
|
||||
|
||||
public async Task StartAsync(ExperionServerConfig cfg)
|
||||
{
|
||||
if (_running)
|
||||
{
|
||||
_logger.LogWarning("[Realtime] 이미 실행 중. 재시작합니다.");
|
||||
await StopAsync();
|
||||
}
|
||||
|
||||
// 플래그 파일 저장 (앱 재기동 시 자동 재시작용)
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(cfg);
|
||||
await File.WriteAllTextAsync(FlagPath, json);
|
||||
_logger.LogInformation("[Realtime] 자동 재시작 플래그 저장: {Path}", FlagPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Realtime] 플래그 파일 저장 실패 (무시)");
|
||||
}
|
||||
|
||||
_currentCfg = cfg;
|
||||
_cts = new CancellationTokenSource();
|
||||
_monitorTask = Task.Run(() => RunLoopAsync(_cts.Token));
|
||||
_logger.LogInformation("[Realtime] 구독 시작 요청: {Url}", cfg.EndpointUrl);
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
// 플래그 파일 삭제 (자동 재시작 비활성화)
|
||||
try
|
||||
{
|
||||
if (File.Exists(FlagPath)) File.Delete(FlagPath);
|
||||
_logger.LogInformation("[Realtime] 자동 재시작 플래그 삭제");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Realtime] 플래그 파일 삭제 실패 (무시)");
|
||||
}
|
||||
|
||||
_cts?.Cancel();
|
||||
|
||||
var tasks = new List<Task>();
|
||||
if (_monitorTask != null) tasks.Add(_monitorTask);
|
||||
if (_flushTask != null) tasks.Add(_flushTask);
|
||||
if (tasks.Count > 0)
|
||||
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
||||
|
||||
await CleanupSessionAsync();
|
||||
_pendingUpdates.Clear();
|
||||
_running = false;
|
||||
_subscribedCount = 0;
|
||||
_statusMsg = "중지됨";
|
||||
_logger.LogInformation("[Realtime] 구독 중지 완료");
|
||||
}
|
||||
|
||||
public RealtimeServiceStatus GetStatus()
|
||||
=> new(_running, _subscribedCount, _statusMsg);
|
||||
|
||||
public async Task<(bool Success, string Message)> AddMonitoredItemAsync(string nodeId)
|
||||
{
|
||||
// 구독 중이 아니면 DB에만 저장된 상태 — 다음 구독 시작 시 자동 포함
|
||||
if (!_running || _subscription == null)
|
||||
return (true, "구독 중 아님 — 다음 구독 시작 시 자동 포함됩니다.");
|
||||
|
||||
var item = new MonitoredItem(_subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = new NodeId(nodeId),
|
||||
AttributeId = Attributes.Value,
|
||||
SamplingInterval = 500,
|
||||
QueueSize = 1,
|
||||
DiscardOldest = true,
|
||||
DisplayName = nodeId
|
||||
};
|
||||
item.Notification += OnNotification;
|
||||
_subscription.AddItem(item);
|
||||
|
||||
try
|
||||
{
|
||||
// OPC UA 서버에 실제 적용 — 서버가 node_id 유효성 검증
|
||||
await Task.Run(() => _subscription.ApplyChanges());
|
||||
|
||||
// 서버 응답 상태 확인 (Error가 null이면 정상)
|
||||
if (item.Status.Error != null && !StatusCode.IsGood(item.Status.Error.StatusCode))
|
||||
{
|
||||
// 유효하지 않은 node_id → subscription에서 제거
|
||||
_subscription.RemoveItem(item);
|
||||
await Task.Run(() => _subscription.ApplyChanges());
|
||||
|
||||
var code = item.Status.Error.StatusCode;
|
||||
_logger.LogWarning("[Realtime] 잘못된 node_id: {NodeId} — {Code}", nodeId, code);
|
||||
return (false, $"OPC UA 서버가 노드를 거부했습니다: {code}");
|
||||
}
|
||||
|
||||
_subscribedCount++;
|
||||
_statusMsg = $"구독 중 ({_subscribedCount}개 포인트)";
|
||||
_logger.LogInformation("[Realtime] 핫 추가 성공: {NodeId}", nodeId);
|
||||
return (true, "구독에 즉시 추가되었습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_subscription.RemoveItem(item);
|
||||
_logger.LogError(ex, "[Realtime] MonitoredItem 추가 실패: {NodeId}", nodeId);
|
||||
return (false, $"MonitoredItem 추가 중 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 내부 루프 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectAndSubscribeAsync(ct);
|
||||
|
||||
// 세션이 살아있는 동안 KeepAlive 대기
|
||||
while (!ct.IsCancellationRequested &&
|
||||
_session != null && _session.Connected)
|
||||
{
|
||||
await Task.Delay(5_000, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_running = false;
|
||||
_statusMsg = $"재연결 대기 중: {ex.Message}";
|
||||
_logger.LogWarning(ex, "[Realtime] 연결 오류, 30초 후 재시도");
|
||||
await CleanupSessionAsync();
|
||||
try { await Task.Delay(30_000, ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
_running = false;
|
||||
_statusMsg = "중지됨";
|
||||
}
|
||||
|
||||
private async Task ConnectAndSubscribeAsync(CancellationToken ct)
|
||||
{
|
||||
if (_currentCfg == null) return;
|
||||
|
||||
_statusMsg = "연결 중...";
|
||||
_logger.LogInformation("[Realtime] OPC UA 접속 시도: {Url}", _currentCfg.EndpointUrl);
|
||||
|
||||
var appConfig = await BuildConfigAsync(_currentCfg);
|
||||
var endpoint = await SelectEndpointAsync(appConfig, _currentCfg.EndpointUrl, ct);
|
||||
_session = await CreateSessionAsync(appConfig, endpoint, _currentCfg);
|
||||
|
||||
_logger.LogInformation("[Realtime] 세션 생성 완료");
|
||||
|
||||
// realtime_table 의 node_id 목록 조회
|
||||
List<RealtimePoint> points;
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
points = (await db.GetRealtimePointsAsync()).ToList();
|
||||
}
|
||||
|
||||
if (points.Count == 0)
|
||||
{
|
||||
_statusMsg = "포인트 없음 (포인트빌더에서 먼저 빌드하세요)";
|
||||
_logger.LogWarning("[Realtime] realtime_table 이 비어 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscription 생성
|
||||
_subscription = new Subscription(_session.DefaultSubscription)
|
||||
{
|
||||
PublishingInterval = 1_000,
|
||||
KeepAliveCount = 10,
|
||||
LifetimeCount = 100,
|
||||
MaxNotificationsPerPublish = 1000,
|
||||
PublishingEnabled = true,
|
||||
Priority = 0
|
||||
};
|
||||
|
||||
// MonitoredItem 등록
|
||||
foreach (var pt in points)
|
||||
{
|
||||
var item = new MonitoredItem(_subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = new NodeId(pt.NodeId),
|
||||
AttributeId = Attributes.Value,
|
||||
SamplingInterval = 500,
|
||||
QueueSize = 1,
|
||||
DiscardOldest = true,
|
||||
DisplayName = pt.NodeId
|
||||
};
|
||||
item.Notification += OnNotification;
|
||||
_subscription.AddItem(item);
|
||||
}
|
||||
|
||||
_session.AddSubscription(_subscription);
|
||||
_subscription.Create();
|
||||
|
||||
_subscribedCount = points.Count;
|
||||
_running = true;
|
||||
_statusMsg = $"구독 중 ({_subscribedCount}개 포인트)";
|
||||
_logger.LogInformation("[Realtime] 구독 완료: {Count}개 포인트", _subscribedCount);
|
||||
|
||||
// 배치 flush 태스크 시작 (콜백 → dictionary → 500ms 단위 배치 DB 업데이트)
|
||||
_flushTask = Task.Run(() => FlushLoopAsync(ct), ct);
|
||||
}
|
||||
|
||||
// 콜백: Task.Run 없이 dictionary에만 기록 (최신 값 덮어쓰기)
|
||||
private void OnNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e)
|
||||
{
|
||||
foreach (var val in item.DequeueValues())
|
||||
{
|
||||
var nodeId = item.DisplayName;
|
||||
var value = val.Value?.ToString();
|
||||
var timestamp = val.SourceTimestamp == DateTime.MinValue ? DateTime.UtcNow : val.SourceTimestamp;
|
||||
_pendingUpdates[nodeId] = (value, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 flush 루프 — 500ms 주기, 단일 DbContext로 일괄 업데이트
|
||||
private async Task FlushLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(500, ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
await FlushPendingAsync();
|
||||
}
|
||||
// 종료 시 남은 항목 최종 flush
|
||||
await FlushPendingAsync();
|
||||
}
|
||||
|
||||
private async Task FlushPendingAsync()
|
||||
{
|
||||
if (_pendingUpdates.IsEmpty) return;
|
||||
|
||||
// 스냅샷 후 제거 (새 콜백은 계속 dictionary에 추가 가능)
|
||||
var snapshot = _pendingUpdates.ToArray();
|
||||
foreach (var kv in snapshot)
|
||||
_pendingUpdates.TryRemove(kv.Key, out _);
|
||||
|
||||
var updates = snapshot
|
||||
.Select(kv => new LiveValueUpdate(kv.Key, kv.Value.value, kv.Value.timestamp))
|
||||
.ToList();
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
var count = await db.BatchUpdateLiveValuesAsync(updates);
|
||||
_logger.LogDebug("[Realtime] 배치 업데이트: {Count}/{Total}건",
|
||||
count, updates.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Realtime] 배치 DB 업데이트 실패");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupSessionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_subscription != null)
|
||||
{
|
||||
_subscription.Delete(true);
|
||||
_subscription = null;
|
||||
}
|
||||
if (_session != null)
|
||||
{
|
||||
if (_session.Connected)
|
||||
await _session.CloseAsync();
|
||||
_session.Dispose();
|
||||
_session = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Realtime] 세션 정리 중 오류 (무시)");
|
||||
}
|
||||
}
|
||||
|
||||
// ── OPC UA 헬퍼 (ExperionOpcClient 와 동일한 패턴) ───────────────────────
|
||||
|
||||
private static async Task<ApplicationConfiguration> BuildConfigAsync(ExperionServerConfig cfg)
|
||||
{
|
||||
var clientCert = ExperionCertificateService.TryLoadCertificate(cfg.ClientHostName);
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "ExperionCrawlerClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationUri = cfg.ApplicationUri,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = clientCert != null
|
||||
? new CertificateIdentifier { Certificate = clientCert }
|
||||
: new CertificateIdentifier(),
|
||||
TrustedPeerCertificates = new CertificateTrustList
|
||||
{ StoreType = "Directory", StorePath = Path.GetFullPath("pki/trusted") },
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{ StoreType = "Directory", StorePath = Path.GetFullPath("pki/issuers") },
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{ StoreType = "Directory", StorePath = Path.GetFullPath("pki/rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true
|
||||
},
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15_000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }
|
||||
};
|
||||
|
||||
await config.ValidateAsync(ApplicationType.Client);
|
||||
config.CertificateValidator.CertificateValidation += (_, e) =>
|
||||
{
|
||||
if (e.Error.StatusCode != StatusCodes.Good) e.Accept = true;
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
|
||||
ApplicationConfiguration appConfig, string endpointUrl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
using var discovery = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, timeoutCts.Token);
|
||||
var endpoints = await discovery.GetEndpointsAsync(null);
|
||||
var selected = endpoints
|
||||
.OrderByDescending(e => e.SecurityLevel)
|
||||
.FirstOrDefault(e => e.SecurityPolicyUri.Contains("Basic256Sha256"))
|
||||
?? endpoints[0];
|
||||
return new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
}
|
||||
|
||||
private static async Task<ISession> CreateSessionAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
ConfiguredEndpoint endpoint,
|
||||
ExperionServerConfig cfg)
|
||||
{
|
||||
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
|
||||
return await Session.Create(
|
||||
appConfig, endpoint, false, "ExperionRealtimeSession", 60_000, identity, null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
CleanupSessionAsync().GetAwaiter().GetResult();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,160 @@ public class ExperionDatabaseController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// ── 포인트빌더 ────────────────────────────────────────────────────────────────
|
||||
|
||||
[ApiController]
|
||||
[Route("api/pointbuilder")]
|
||||
public class ExperionPointBuilderController : ControllerBase
|
||||
{
|
||||
private readonly IExperionDbService _dbSvc;
|
||||
private readonly IExperionRealtimeService _realtimeSvc;
|
||||
public ExperionPointBuilderController(
|
||||
IExperionDbService dbSvc,
|
||||
IExperionRealtimeService realtimeSvc)
|
||||
{
|
||||
_dbSvc = dbSvc;
|
||||
_realtimeSvc = realtimeSvc;
|
||||
}
|
||||
|
||||
/// <summary>node_map_master → realtime_table 빌드 (기존 데이터 전체 교체)</summary>
|
||||
[HttpPost("build")]
|
||||
public async Task<IActionResult> Build([FromBody] PointBuilderBuildDto dto)
|
||||
{
|
||||
var count = await _dbSvc.BuildRealtimeTableAsync(dto.Names, dto.DataTypes);
|
||||
return Ok(new { success = true, count, message = $"{count}개 포인트 생성 완료" });
|
||||
}
|
||||
|
||||
/// <summary>realtime_table 전체 조회</summary>
|
||||
[HttpGet("points")]
|
||||
public async Task<IActionResult> GetPoints()
|
||||
{
|
||||
var points = await _dbSvc.GetRealtimePointsAsync();
|
||||
return Ok(new
|
||||
{
|
||||
count = points.Count(),
|
||||
points = points.Select(p => new
|
||||
{
|
||||
p.Id, p.TagName, p.NodeId, p.LiveValue,
|
||||
timestamp = p.Timestamp
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>node_id 를 직접 입력해서 수동 추가</summary>
|
||||
[HttpPost("add")]
|
||||
public async Task<IActionResult> Add([FromBody] PointBuilderAddDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.NodeId))
|
||||
return BadRequest(new { success = false, message = "node_id 는 필수입니다." });
|
||||
|
||||
var nodeId = dto.NodeId.Trim();
|
||||
|
||||
// DB에 먼저 추가
|
||||
var point = await _dbSvc.AddRealtimePointAsync(nodeId);
|
||||
|
||||
// 구독 중이면 OPC UA 서버에 핫 추가 및 node_id 유효성 검증
|
||||
var (ok, msg) = await _realtimeSvc.AddMonitoredItemAsync(nodeId);
|
||||
if (!ok)
|
||||
{
|
||||
// OPC UA 서버가 거부 → DB 롤백
|
||||
await _dbSvc.DeleteRealtimePointAsync(point.Id);
|
||||
return BadRequest(new { success = false, message = msg });
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = msg, point = new { point.Id, point.TagName, point.NodeId } });
|
||||
}
|
||||
|
||||
/// <summary>포인트 삭제</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var deleted = await _dbSvc.DeleteRealtimePointAsync(id);
|
||||
return Ok(new { success = deleted, message = deleted ? "삭제 완료" : "포인트를 찾을 수 없습니다." });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 실시간 서비스 ─────────────────────────────────────────────────────────────
|
||||
|
||||
[ApiController]
|
||||
[Route("api/realtime")]
|
||||
public class ExperionRealtimeController : ControllerBase
|
||||
{
|
||||
private readonly IExperionRealtimeService _rtSvc;
|
||||
public ExperionRealtimeController(IExperionRealtimeService rtSvc) => _rtSvc = rtSvc;
|
||||
|
||||
[HttpPost("start")]
|
||||
public async Task<IActionResult> Start([FromBody] ExperionServerConfigDto dto)
|
||||
{
|
||||
var cfg = MapConfig(dto);
|
||||
await _rtSvc.StartAsync(cfg);
|
||||
return Ok(new { success = true, message = "실시간 구독 시작" });
|
||||
}
|
||||
|
||||
[HttpPost("stop")]
|
||||
public async Task<IActionResult> Stop()
|
||||
{
|
||||
await _rtSvc.StopAsync();
|
||||
return Ok(new { success = true, message = "실시간 구독 중지" });
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public IActionResult Status()
|
||||
{
|
||||
var s = _rtSvc.GetStatus();
|
||||
return Ok(new { running = s.Running, subscribedCount = s.SubscribedCount, message = s.Message });
|
||||
}
|
||||
|
||||
private static ExperionServerConfig MapConfig(ExperionServerConfigDto dto) => new()
|
||||
{
|
||||
ServerHostName = dto.ServerHostName,
|
||||
Port = dto.Port,
|
||||
ClientHostName = dto.ClientHostName,
|
||||
UserName = dto.UserName,
|
||||
Password = dto.Password
|
||||
};
|
||||
}
|
||||
|
||||
// ── 이력 조회 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[ApiController]
|
||||
[Route("api/history")]
|
||||
public class ExperionHistoryController : ControllerBase
|
||||
{
|
||||
private readonly IExperionDbService _dbSvc;
|
||||
public ExperionHistoryController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
|
||||
|
||||
/// <summary>realtime_table 의 tagname 목록</summary>
|
||||
[HttpGet("tagnames")]
|
||||
public async Task<IActionResult> TagNames()
|
||||
{
|
||||
var names = await _dbSvc.GetTagNamesAsync();
|
||||
return Ok(new { tagNames = names });
|
||||
}
|
||||
|
||||
/// <summary>이력 조회 (tagname 다중, 시간범위, limit)</summary>
|
||||
[HttpGet("query")]
|
||||
public async Task<IActionResult> Query(
|
||||
[FromQuery] List<string>? tagNames,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int limit = 1000)
|
||||
{
|
||||
var result = await _dbSvc.QueryHistoryAsync(
|
||||
tagNames ?? Enumerable.Empty<string>(), from, to, limit);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
tagNames = result.TagNames,
|
||||
rows = result.Rows.Select(r => new
|
||||
{
|
||||
recordedAt = r.RecordedAt,
|
||||
values = r.Values
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 노드맵 대시보드 ───────────────────────────────────────────────────────────
|
||||
|
||||
[ApiController]
|
||||
|
||||
@@ -30,6 +30,14 @@ builder.Services.AddScoped<IExperionDbService, ExperionDbService>();
|
||||
// ── Application Services ──────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<ExperionCrawlService>();
|
||||
|
||||
// ── Realtime & History BackgroundServices ─────────────────────────────────────
|
||||
builder.Services.AddSingleton<ExperionRealtimeService>();
|
||||
builder.Services.AddSingleton<IExperionRealtimeService>(
|
||||
sp => sp.GetRequiredService<ExperionRealtimeService>());
|
||||
builder.Services.AddHostedService(
|
||||
sp => sp.GetRequiredService<ExperionRealtimeService>());
|
||||
builder.Services.AddHostedService<ExperionHistoryService>();
|
||||
|
||||
// ── CORS ──────────────────────────────────────────────────────────────────────
|
||||
builder.Services.AddCors(opt =>
|
||||
opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -13,10 +13,10 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("ExperionCrawler")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+323aec34af7ca04f7d345dbb888bd9eb45bcde93")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("ExperionCrawler")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("ExperionCrawler")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
// Generated by the MSBuild WriteCodeFragment class.
|
||||
// MSBuild WriteCodeFragment 클래스에서 생성되었습니다.
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
6fe0aa0c1e113dfddc8d298c5392596b470d6802f7a2452a91ed847dfb69b8fc
|
||||
bd5528d96831e1269e5461c65f271393ce4b10f88cb7442548dd757f5587c262
|
||||
|
||||
@@ -1 +1 @@
|
||||
c2979188431fb624c2c87088b22964000f18b64a1aa7c9bb462a3f0c790b7bdc
|
||||
e033eed09e2f3d3d2e6ef61be0e2e69b055deb90173793eb30e2e1551c010c0a
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -486,6 +486,18 @@ tr:last-child td { border-bottom: none; }
|
||||
.nm-name-selects { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* ── 포인트빌더 ──────────────────────────────────────────── */
|
||||
.pb-name-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pb-name-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* ── Utility ─────────────────────────────────────────────── */
|
||||
.hidden { display: none !important; }
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@
|
||||
<span class="ni">05</span>
|
||||
<span class="nl">노드맵 대시보드</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="pb">
|
||||
<span class="ni">06</span>
|
||||
<span class="nl">포인트빌더</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="hist">
|
||||
<span class="ni">07</span>
|
||||
<span class="nl">이력 조회</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sb-foot">
|
||||
@@ -308,9 +316,6 @@
|
||||
<div class="pane-tag">NODE MAP / MASTER</div>
|
||||
</header>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div id="nm-stat-row" class="nm-stat-row hidden"></div>
|
||||
|
||||
<!-- 필터 카드 -->
|
||||
<div class="card">
|
||||
<div class="card-cap">필터 조건</div>
|
||||
@@ -336,15 +341,16 @@
|
||||
<input id="nf-nid" class="inp" placeholder="포함 검색"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>데이터 타입</label>
|
||||
<select id="nf-dtype" class="inp">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
<label>데이터 타입 <em>(직접 입력)</em></label>
|
||||
<input id="nf-dtype" class="inp" placeholder="예: Double, Int32"/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 이름 OR 조건 선택 (최대 4개) -->
|
||||
<!-- 이름 OR 조건 선택 (최대 4개) — 불러오기 버튼으로 옵션 채움 -->
|
||||
<div class="fg nm-name-row">
|
||||
<label>이름 선택 <em>(OR 조건, 최대 4개)</em></label>
|
||||
<label style="display:flex;align-items:center;gap:8px">
|
||||
이름 선택 <em>(OR 조건, 최대 4개)</em>
|
||||
<button class="btn-b btn-sm" onclick="nmLoadNames()" style="margin-left:4px">▼ 옵션 불러오기</button>
|
||||
</label>
|
||||
<div class="nm-name-selects">
|
||||
<select id="nf-name-1" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="nf-name-2" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
|
||||
@@ -377,6 +383,156 @@
|
||||
<div id="nm-table" class="tbl-wrap hidden"></div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
06 포인트빌더
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane" id="pane-pb">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>포인트빌더</h1>
|
||||
<p>node_map_master 에서 실시간 모니터링할 포인트를 선택해 realtime_table 을 구성합니다.</p>
|
||||
</div>
|
||||
<div class="pane-tag">REALTIME / BUILD</div>
|
||||
</header>
|
||||
|
||||
<!-- 빌더 카드 -->
|
||||
<div class="cols-2">
|
||||
<div class="card">
|
||||
<div class="card-cap">조건으로 테이블 작성</div>
|
||||
<div class="fg">
|
||||
<label>이름(name) 선택 <em>(OR 조건, 최대 8개)</em>
|
||||
<button class="btn-b btn-sm" onclick="pbLoad()" style="margin-left:4px">▼ 옵션 불러오기</button>
|
||||
</label>
|
||||
<div class="pb-name-grid" id="pb-name-grid">
|
||||
<!-- JS 에서 드롭다운 동적 생성 -->
|
||||
<select id="pb-n1" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n2" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n3" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n4" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n5" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n6" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n7" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n8" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>데이터 타입(data_type) 직접 입력 <em>(OR 조건, 최대 2개)</em></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<input id="pb-dt1" class="inp" placeholder="예: Double"/>
|
||||
<input id="pb-dt2" class="inp" placeholder="예: Int32"/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
|
||||
<div id="pb-build-log" class="logbox hidden" style="margin-top:10px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-cap">수동 포인트 추가</div>
|
||||
<div class="fg">
|
||||
<label>Node ID 직접 입력</label>
|
||||
<input id="pb-manual-nid" class="inp" placeholder="ns=2;s=Honeywell.Experion..."/>
|
||||
</div>
|
||||
<button class="btn-b" onclick="pbAddManual()">+ 추가</button>
|
||||
<div id="pb-manual-log" class="logbox hidden" style="margin-top:10px"></div>
|
||||
|
||||
<div class="card-cap" style="margin-top:20px">실시간 구독 제어</div>
|
||||
<div class="cols-2" style="gap:8px;margin-bottom:10px">
|
||||
<div class="fg">
|
||||
<label>서버 IP</label>
|
||||
<input id="pb-rt-ip" class="inp" value="192.168.0.20"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>포트</label>
|
||||
<input id="pb-rt-port" class="inp" type="number" value="4840"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>클라이언트 호스트</label>
|
||||
<input id="pb-rt-client" class="inp" value="dbsvr"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>계정</label>
|
||||
<input id="pb-rt-user" class="inp" value="mngr"/>
|
||||
</div>
|
||||
<div class="fg" style="grid-column:1/-1">
|
||||
<label>비밀번호</label>
|
||||
<input id="pb-rt-pw" class="inp" type="password" value="mngr"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-a" onclick="rtStart()">▶ 구독 시작</button>
|
||||
<button class="btn-b" onclick="rtStop()">■ 구독 중지</button>
|
||||
<button class="btn-b btn-sm" onclick="rtStatus()">상태 확인</button>
|
||||
</div>
|
||||
<div id="pb-rt-status" class="logbox hidden" style="margin-top:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 포인트 목록 -->
|
||||
<div class="card" style="margin-top:0">
|
||||
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>포인트 목록 <span id="pb-count" class="mut">(0개)</span></span>
|
||||
<button class="btn-b btn-sm" onclick="pbRefresh()">↻ 새로 고침</button>
|
||||
</div>
|
||||
<div id="pb-table" class="tbl-wrap">
|
||||
<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
07 이력 조회
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane" id="pane-hist">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>이력 조회</h1>
|
||||
<p>history_table 의 시계열 데이터를 조회합니다.</p>
|
||||
</div>
|
||||
<div class="pane-tag">HISTORY / TREND</div>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-cap">조회 조건</div>
|
||||
<div class="fg">
|
||||
<label style="display:flex;align-items:center;gap:8px">
|
||||
태그 선택 <em>(최대 8개, OR 조건)</em>
|
||||
<button class="btn-b btn-sm" onclick="histLoad()" style="margin-left:4px">▼ 옵션 불러오기</button>
|
||||
</label>
|
||||
<div class="pb-name-grid">
|
||||
<select id="hf-t1" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="hf-t2" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="hf-t3" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="hf-t4" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="hf-t5" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="hf-t6" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="hf-t7" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="hf-t8" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cols-3">
|
||||
<div class="fg">
|
||||
<label>시작 시간</label>
|
||||
<input id="hf-from" class="inp" type="datetime-local"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>종료 시간</label>
|
||||
<input id="hf-to" class="inp" type="datetime-local"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>최대 행 수</label>
|
||||
<input id="hf-limit" class="inp" type="number" value="500" min="10" max="5000"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-a" onclick="histQuery()">🔍 조회</button>
|
||||
<button class="btn-b" onclick="histReset()">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hist-result-info" class="nm-result-info hidden" style="margin:8px 0"></div>
|
||||
<div id="hist-table" class="tbl-wrap hidden"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ document.querySelectorAll('.nav-item').forEach(item => {
|
||||
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
document.getElementById(`pane-${tab}`).classList.add('active');
|
||||
if (tab === 'nm-dash') nmLoad();
|
||||
// nm-dash: 탭 진입 시 API 호출 없음 — 조회 버튼으로만 동작
|
||||
// pb: 탭 진입 시 API 호출 없음 — ▼ 옵션 불러오기 버튼으로만 동작
|
||||
// hist: 탭 진입 시 API 호출 없음
|
||||
});
|
||||
});
|
||||
|
||||
@@ -412,30 +414,10 @@ let _nmOffset = 0;
|
||||
let _nmTotal = 0;
|
||||
let _nmLimit = 100;
|
||||
|
||||
async function nmLoad() {
|
||||
// 이름 드롭다운 옵션만 필요할 때 수동으로 불러오는 함수 (버튼 클릭 시)
|
||||
async function nmLoadNames() {
|
||||
try {
|
||||
const [s, n] = await Promise.all([
|
||||
api('GET', '/api/nodemap/stats'),
|
||||
api('GET', '/api/nodemap/names')
|
||||
]);
|
||||
|
||||
const row = document.getElementById('nm-stat-row');
|
||||
row.classList.remove('hidden');
|
||||
row.innerHTML = `
|
||||
<div class="stat"><div class="sv">${s.total.toLocaleString()}</div><div class="sk">전체 노드</div></div>
|
||||
<div class="stat"><div class="sv" style="color:var(--a)">${s.objectCount.toLocaleString()}</div><div class="sk">Object</div></div>
|
||||
<div class="stat"><div class="sv" style="color:var(--grn)">${s.variableCount.toLocaleString()}</div><div class="sk">Variable</div></div>
|
||||
<div class="stat"><div class="sv" style="color:var(--blu)">${s.maxLevel}</div><div class="sk">최대 깊이</div></div>
|
||||
<div class="stat"><div class="sv">${(s.dataTypes||[]).length}</div><div class="sk">데이터 타입 종류</div></div>
|
||||
`;
|
||||
|
||||
// 데이터타입 드롭다운 채우기
|
||||
const sel = document.getElementById('nf-dtype');
|
||||
const curDtype = sel.value;
|
||||
sel.innerHTML = '<option value="">전체</option>' +
|
||||
(s.dataTypes||[]).map(t => `<option value="${esc(t)}"${t===curDtype?' selected':''}>${esc(t)}</option>`).join('');
|
||||
|
||||
// 이름 드롭다운 4개 채우기
|
||||
const n = await api('GET', '/api/nodemap/names');
|
||||
const nameOpts = '<option value="">— 선택 안 함 —</option>' +
|
||||
(n.names||[]).map(nm => `<option value="${esc(nm)}">${esc(nm)}</option>`).join('');
|
||||
['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => {
|
||||
@@ -444,9 +426,7 @@ async function nmLoad() {
|
||||
el.innerHTML = nameOpts;
|
||||
if (cur) el.value = cur;
|
||||
});
|
||||
|
||||
if (s.total > 0) nmQuery(0);
|
||||
} catch (e) { console.error('nmLoad:', e); }
|
||||
} catch (e) { console.error('nmLoadNames:', e); }
|
||||
}
|
||||
|
||||
async function nmQuery(offset) {
|
||||
@@ -458,7 +438,7 @@ async function nmQuery(offset) {
|
||||
const lvMax = document.getElementById('nf-lv-max').value.trim();
|
||||
const cls = document.getElementById('nf-class').value;
|
||||
const nid = document.getElementById('nf-nid').value.trim();
|
||||
const dtype = document.getElementById('nf-dtype').value;
|
||||
const dtype = document.getElementById('nf-dtype').value.trim();
|
||||
const selNames = ['nf-name-1','nf-name-2','nf-name-3','nf-name-4']
|
||||
.map(id => document.getElementById(id).value)
|
||||
.filter(Boolean);
|
||||
@@ -543,7 +523,236 @@ function nmReset() {
|
||||
['nf-name-1','nf-name-2','nf-name-3','nf-name-4'].forEach(id => {
|
||||
document.getElementById(id).value = '';
|
||||
});
|
||||
nmQuery(0);
|
||||
// 초기화 후 자동 조회 없음
|
||||
document.getElementById('nm-result-bar').classList.add('hidden');
|
||||
document.getElementById('nm-table').classList.add('hidden');
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
06 포인트빌더
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8'];
|
||||
const PB_DT_IDS = ['pb-dt1','pb-dt2'];
|
||||
|
||||
async function pbLoad() {
|
||||
try {
|
||||
const n = await api('GET', '/api/nodemap/names');
|
||||
const nameOpts = '<option value="">— 선택 안 함 —</option>' +
|
||||
(n.names||[]).map(nm => `<option value="${esc(nm)}">${esc(nm)}</option>`).join('');
|
||||
PB_NAME_IDS.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const cur = el.value;
|
||||
el.innerHTML = nameOpts;
|
||||
if (cur) el.value = cur;
|
||||
});
|
||||
// 데이터타입은 text input으로 직접 입력 — stats API 호출 없음
|
||||
// 탭 진입 시 자동 조회 없음 — ↻ 새로 고침 버튼을 눌러야만 목록 표시됨
|
||||
} catch (e) { console.error('pbLoad:', e); }
|
||||
}
|
||||
|
||||
async function pbRefresh() {
|
||||
try {
|
||||
const d = await api('GET', '/api/pointbuilder/points');
|
||||
document.getElementById('pb-count').textContent = `(${d.count}개)`;
|
||||
pbRender(d.points || []);
|
||||
rtStatus();
|
||||
} catch (e) { console.error('pbRefresh:', e); }
|
||||
}
|
||||
|
||||
function pbRender(points) {
|
||||
const tbl = document.getElementById('pb-table');
|
||||
if (!points.length) {
|
||||
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>';
|
||||
return;
|
||||
}
|
||||
tbl.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>TagName</th><th>Node ID</th>
|
||||
<th>LiveValue</th><th>Timestamp</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${points.map(p => `
|
||||
<tr>
|
||||
<td class="mut">${p.id}</td>
|
||||
<td style="font-weight:600">${esc(p.tagName)}</td>
|
||||
<td class="mut" style="font-size:11px;font-family:var(--fm)">${esc(p.nodeId)}</td>
|
||||
<td class="val">${p.liveValue != null ? esc(p.liveValue) : '<span style="color:var(--t3)">—</span>'}</td>
|
||||
<td class="mut" style="font-size:11px">${p.liveValue != null ? new Date(p.timestamp).toLocaleString('ko-KR') : '—'}</td>
|
||||
<td><button class="btn-sm btn-b" style="color:var(--red,#e55)" onclick="pbDelete(${p.id})">✕</button></td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
async function pbBuild() {
|
||||
const names = PB_NAME_IDS.map(id => document.getElementById(id).value).filter(Boolean);
|
||||
const dataTypes = PB_DT_IDS.map(id => document.getElementById(id).value).filter(Boolean);
|
||||
|
||||
setGlobal('busy', '포인트 빌드 중');
|
||||
try {
|
||||
const d = await api('POST', '/api/pointbuilder/build', { names, dataTypes });
|
||||
log('pb-build-log', [{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }]);
|
||||
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트` : '빌드 실패');
|
||||
if (d.success) await pbRefresh();
|
||||
} catch (e) {
|
||||
log('pb-build-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
setGlobal('err', '오류');
|
||||
}
|
||||
}
|
||||
|
||||
async function pbAddManual() {
|
||||
const nodeId = document.getElementById('pb-manual-nid').value.trim();
|
||||
if (!nodeId) { alert('Node ID를 입력하세요.'); return; }
|
||||
try {
|
||||
const d = await api('POST', '/api/pointbuilder/add', { nodeId });
|
||||
log('pb-manual-log', [{ c: d.success ? 'ok' : 'err', t: d.success
|
||||
? `✅ 추가됨: ${d.point?.tagName} (${d.point?.nodeId})`
|
||||
: '❌ 추가 실패' }]);
|
||||
if (d.success) {
|
||||
document.getElementById('pb-manual-nid').value = '';
|
||||
await pbRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
log('pb-manual-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
}
|
||||
}
|
||||
|
||||
async function pbDelete(id) {
|
||||
if (!confirm(`포인트 #${id}를 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
const d = await api('DELETE', `/api/pointbuilder/${id}`);
|
||||
if (d.success) await pbRefresh();
|
||||
else alert('삭제 실패: ' + d.message);
|
||||
} catch (e) { alert('삭제 오류: ' + e.message); }
|
||||
}
|
||||
|
||||
/* ── 실시간 구독 제어 ────────────────────────────────────────── */
|
||||
async function rtStart() {
|
||||
const body = {
|
||||
serverHostName: document.getElementById('pb-rt-ip').value.trim(),
|
||||
port: parseInt(document.getElementById('pb-rt-port').value) || 4840,
|
||||
clientHostName: document.getElementById('pb-rt-client').value.trim(),
|
||||
userName: document.getElementById('pb-rt-user').value.trim(),
|
||||
password: document.getElementById('pb-rt-pw').value
|
||||
};
|
||||
setGlobal('busy', '구독 시작 중');
|
||||
try {
|
||||
const d = await api('POST', '/api/realtime/start', body);
|
||||
log('pb-rt-status', [{ c: 'ok', t: '▶ ' + d.message }]);
|
||||
setGlobal('ok', '구독 중');
|
||||
} catch (e) {
|
||||
log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
setGlobal('err', '오류');
|
||||
}
|
||||
}
|
||||
|
||||
async function rtStop() {
|
||||
setGlobal('busy', '구독 중지 중');
|
||||
try {
|
||||
const d = await api('POST', '/api/realtime/stop');
|
||||
log('pb-rt-status', [{ c: 'inf', t: '■ ' + d.message }]);
|
||||
setGlobal('ok', '중지됨');
|
||||
} catch (e) { log('pb-rt-status', [{ c: 'err', t: '❌ ' + e.message }]); }
|
||||
}
|
||||
|
||||
async function rtStatus() {
|
||||
try {
|
||||
const d = await api('GET', '/api/realtime/status');
|
||||
const logEl = document.getElementById('pb-rt-status');
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = `<div class="ll ${d.running ? 'ok' : 'inf'}">
|
||||
${d.running ? '▶' : '■'} ${esc(d.message)} (구독 ${d.subscribedCount}개)
|
||||
</div>`;
|
||||
} catch (e) { /* 무시 */ }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
07 이력 조회
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const HIST_TAG_IDS = ['hf-t1','hf-t2','hf-t3','hf-t4','hf-t5','hf-t6','hf-t7','hf-t8'];
|
||||
|
||||
// "▼ 옵션 불러오기" 버튼 클릭 시에만 호출 — 탭 진입 시 자동 호출 없음
|
||||
async function histLoad() {
|
||||
try {
|
||||
const d = await api('GET', '/api/history/tagnames');
|
||||
const opts = '<option value="">— 선택 안 함 —</option>' +
|
||||
(d.tagNames||[]).map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
|
||||
HIST_TAG_IDS.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const cur = el.value;
|
||||
el.innerHTML = opts;
|
||||
if (cur) el.value = cur;
|
||||
});
|
||||
} catch (e) { console.error('histLoad:', e); }
|
||||
}
|
||||
|
||||
async function histQuery() {
|
||||
const tags = HIST_TAG_IDS.map(id => document.getElementById(id).value).filter(Boolean);
|
||||
const from = document.getElementById('hf-from').value;
|
||||
const to = document.getElementById('hf-to').value;
|
||||
const limit = parseInt(document.getElementById('hf-limit').value) || 500;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
tags.forEach(t => params.append('tagNames', t));
|
||||
if (from) params.set('from', new Date(from).toISOString());
|
||||
if (to) params.set('to', new Date(to).toISOString());
|
||||
params.set('limit', limit);
|
||||
|
||||
setGlobal('busy', '이력 조회 중');
|
||||
try {
|
||||
const d = await api('GET', `/api/history/query?${params}`);
|
||||
const rows = d.rows || [];
|
||||
const tNames = d.tagNames || [];
|
||||
|
||||
const info = document.getElementById('hist-result-info');
|
||||
info.classList.remove('hidden');
|
||||
info.textContent = `총 ${rows.length.toLocaleString()}행 × ${tNames.length}개 태그`;
|
||||
|
||||
const tbl = document.getElementById('hist-table');
|
||||
tbl.classList.remove('hidden');
|
||||
if (!rows.length) {
|
||||
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 이력이 없습니다.</div>';
|
||||
setGlobal('ok', '0건');
|
||||
return;
|
||||
}
|
||||
|
||||
tbl.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시각</th>
|
||||
${tNames.map(t => `<th>${esc(t)}</th>`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map(r => `
|
||||
<tr>
|
||||
<td class="mut" style="white-space:nowrap">${new Date(r.recordedAt).toLocaleString('ko-KR')}</td>
|
||||
${tNames.map(t => `<td class="val">${r.values?.[t] != null ? esc(r.values[t]) : '<span style="color:var(--t3)">—</span>'}</td>`).join('')}
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
setGlobal('ok', `${rows.length}건`);
|
||||
} catch (e) {
|
||||
setGlobal('err', '조회 실패');
|
||||
console.error('histQuery:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function histReset() {
|
||||
HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; });
|
||||
document.getElementById('hf-from').value = '';
|
||||
document.getElementById('hf-to').value = '';
|
||||
document.getElementById('hf-limit').value = '500';
|
||||
document.getElementById('hist-result-info').classList.add('hidden');
|
||||
document.getElementById('hist-table').classList.add('hidden');
|
||||
}
|
||||
|
||||
/* ── 초기 실행 ───────────────────────────────────────────────── */
|
||||
|
||||
33
todo.md
Normal file
33
todo.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 1. Experion Server에서 데이터를 리얼타임으로 가져와서 저장하는 테이블 만들기
|
||||
- 1. RealtimeTable은 tagname, node_id, livevalue, timestamp 컬럼으로 구성되어야 함.
|
||||
- 2. RealtimeTable node_map_master에서 조합 추출한다.
|
||||
SELECT *
|
||||
FROM node_map_master
|
||||
WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue')
|
||||
AND data_type = 'Double';
|
||||
을 데이터 베이스 레코드로 삽입
|
||||
- 3. tagname 컬럼은 2.항에서 추출된 레코드의 node_id 에서 오른쪽 끝에서 ':'문자를 만나기 전까지의 문자열로 채운다 (실제로 운전자가 사용하는 태그명이 된다.)
|
||||
|
||||
- 4. 웹페이지 :테이블 만들기 기능은 별도의 웹페이지 '포인트빌더' 대시보드를 추가하여 구현한다.
|
||||
SELECT *
|
||||
FROM node_map_master
|
||||
WHERE name IN ('pv', 'sp', 'op', 'qv', 'qv.value', 'qv.fieldvalue')
|
||||
AND data_type = 'Double';의 항목을 선택하는 드롭다운 메뉴 항목을
|
||||
name = 8개
|
||||
data_type = 2개 (data_type 드롭다운 항목은 노드맵대시보드 페이지의 '데이터 타입'항목 참조)
|
||||
테이블 작성하기 버튼
|
||||
|
||||
- 5. node_id 를 직접입력하여 수동 추가 하는 항목도 만들어줘
|
||||
|
||||
- 6. 약 2000여개의 데이터 이므로 테이블 구조 설계를 잘해야 함
|
||||
|
||||
|
||||
# 2. 실시간 opcUA 서버 데이터 를 RealtimeTable 레코드의 livevalue 컬럼에 넣는 로직만들기
|
||||
- 1. opcUA 서버는 값이 변경되지 않으면 값을 주지 않는다, opcUA 통신 규약을 참조하여 실시간 데이터 업데이트 로직만들기
|
||||
|
||||
# 3. HistoryTable 만들기
|
||||
- 1. 위의 RealtimeTable의 실시간 값을 정해진 시간마다 시계열 데이터로 저장하는 HistoryTable을 만들어서 레코드 기록하는 로직만들기
|
||||
|
||||
# 4. HistoryTable의 웹페이지 추가
|
||||
- 1. 표시 테이블 컬럼은 드롭다운 으로 선택 , 한 테이블에 8개 까지 선택가능하게
|
||||
- 2. 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시
|
||||
Reference in New Issue
Block a user