Realtime DB 추가 및 Historical DB추가

This commit is contained in:
windpacer
2026-04-14 09:56:37 +00:00
parent 323aec34af
commit 68758f1bb8
23 changed files with 1743 additions and 47 deletions

365
CLAUDE.md
View File

@@ -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` | 이력 조회 전용 스타일 추가 |

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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
{

View File

@@ -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,

View 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] 종료");
}
}

View File

@@ -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);

View 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();
}
}

View File

@@ -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]

View File

@@ -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()));

View File

@@ -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 클래스에서 생성되었습니다.

View File

@@ -1 +1 @@
6fe0aa0c1e113dfddc8d298c5392596b470d6802f7a2452a91ed847dfb69b8fc
bd5528d96831e1269e5461c65f271393ce4b10f88cb7442548dd757f5587c262

View File

@@ -1 +1 @@
c2979188431fb624c2c87088b22964000f18b64a1aa7c9bb462a3f0c790b7bdc
e033eed09e2f3d3d2e6ef61be0e2e69b055deb90173793eb30e2e1551c010c0a

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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
View 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. 시작 시간과 종료 시간 선택 한 범위내에서만 테이블에 표시